fix: refactor worksheet config persistence to blacklist approach + Storybook stories
This commit addresses a critical bug where shared worksheets showed incorrect page counts,
and refactors the config extraction architecture to prevent future sharing bugs.
## Bug Fix: Shared Worksheet Page Count
**Problem:** When sharing a 100-page worksheet, opening the share link showed only 4 pages.
**Root Cause:**
- `extractConfigFields()` correctly excluded `total` (derived state)
- `validateWorksheetConfig()` incorrectly used `formState.total ?? 20` as fallback
- Result: Shared worksheets defaulted to `total: 20` instead of calculating from `pages × problemsPerPage`
**Solution:**
- Calculate `total = problemsPerPage × pages` from PRIMARY state
- Never use `formState.total` as source of truth - it's derived!
```typescript
// Before (bug)
const total = formState.total ?? 20
// After (fix)
const problemsPerPage = formState.problemsPerPage ?? 20
const pages = formState.pages ?? 1
const total = problemsPerPage * pages // 100 pages × 20 problems = 2000 ✓
```
## Architecture: Blacklist Refactor
**Problem:** Multiple incidents where new config fields broke shared worksheets because
we forgot to update the extraction whitelist.
**Old Approach (FRAGILE):**
```typescript
// Manually list every field - easy to forget new ones!
return {
problemsPerPage: formState.problemsPerPage,
cols: formState.cols,
// ... 30+ fields manually listed
}
```
**New Approach (ROBUST):**
```typescript
// Automatically include everything except derived/ephemeral fields
const { rows, total, date, ...persistedFields } = formState
return persistedFields
```
**Benefits:**
- ✅ New config fields automatically work in shared worksheets
- ✅ Only need to update if adding DERIVED fields (rare)
- ✅ Much harder to accidentally break sharing
## Documentation
Added comprehensive docs explaining PRIMARY vs DERIVED state:
**Architecture docs:**
- `.claude/WORKSHEET_CONFIG_PERSISTENCE.md` - Full architecture, examples, history
- `src/app/create/worksheets/README_CONFIG_PERSISTENCE.md` - Quick reference
**In-code docs:**
- `extractConfigFields.ts` - Blacklist approach, field categories, usage
- `validation.ts` - PRIMARY → DERIVED state calculation with examples
- `types.ts` - Field category markers (PRIMARY/DERIVED/EPHEMERAL)
## Storybook Stories
Added comprehensive Storybook stories for worksheet generator components:
**Component Stories:**
- `AdditionWorksheetClient.stories.tsx` - Full generator with virtual loading demos
- `ConfigSidebar.stories.tsx` - Configuration panel with all tabs
- `ResponsivePanelLayout.stories.tsx` - Desktop/mobile layout variations
- `WorksheetPreview.stories.tsx` - Preview component with different states
- `PagePlaceholder.stories.tsx` - Animated loading placeholders
**Config Panel Stories:**
- `OverallDifficultySlider.stories.tsx` - Difficulty slider variations
- `RuleDropdown.stories.tsx` - Display rule dropdown states
- `ToggleOption.stories.tsx` - Toggle option component
**Placeholder Enhancements:**
- Added fast-cycling animated problem variations (0.5-1s cycles)
- Bigger, bolder, higher-contrast math elements
- Removed redundant "Loading page..." overlay
- Added keyframes: wiggle, shimmer, fadeInScale, slideInRight, morphWidth, colorShift
**Storybook Config:**
- Renamed `preview.ts` → `preview.tsx` for JSX support
- Updated `main.ts` for Panda CSS integration
## Testing
Manual test checklist for sharing:
1. Create 100-page worksheet (2000 problems)
2. Click "Share"
3. Open share link in incognito
4. Verify: Shows 100 pages, 2000 total problems ✓
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
65ebd7dbcc
commit
5b6db588a2
|
|
@ -0,0 +1,426 @@
|
|||
# Worksheet Config Persistence Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how worksheet configurations are persisted, shared, and restored across the application.
|
||||
|
||||
**Key Principle:** We separate **PRIMARY STATE** (what we save) from **DERIVED STATE** (what we calculate).
|
||||
|
||||
## Field Categories
|
||||
|
||||
### PRIMARY STATE (Persisted)
|
||||
|
||||
These fields define the worksheet configuration and MUST be saved:
|
||||
|
||||
```typescript
|
||||
{
|
||||
// Structure
|
||||
problemsPerPage: number // How many problems per page (e.g., 20)
|
||||
cols: number // Grid columns (e.g., 4)
|
||||
pages: number // How many pages (e.g., 5)
|
||||
orientation: 'portrait' | 'landscape'
|
||||
|
||||
// Problem Space
|
||||
digitRange: { min: number, max: number } // 1-5 digits
|
||||
operator: 'addition' | 'subtraction' | 'mixed'
|
||||
|
||||
// Regrouping Distribution
|
||||
pAnyStart: number // Probability of any-column regrouping
|
||||
pAllStart: number // Probability of all-column regrouping
|
||||
interpolate: boolean // Gradual difficulty progression
|
||||
|
||||
// Display Mode (discriminated union)
|
||||
mode: 'smart' | 'manual' | 'mastery'
|
||||
|
||||
// Smart Mode Fields
|
||||
displayRules?: { // Conditional per-problem scaffolding
|
||||
tenFrames: 'never' | 'sometimes' | 'always'
|
||||
carryBoxes: 'never' | 'sometimes' | 'always'
|
||||
placeValueColors: 'never' | 'sometimes' | 'always'
|
||||
answerBoxes: 'never' | 'sometimes' | 'always'
|
||||
problemNumbers: 'never' | 'sometimes' | 'always'
|
||||
cellBorders: 'never' | 'sometimes' | 'always'
|
||||
borrowNotation: 'never' | 'sometimes' | 'always'
|
||||
borrowingHints: 'never' | 'sometimes' | 'always'
|
||||
}
|
||||
difficultyProfile?: string // Smart mode preset (e.g., 'earlyLearner')
|
||||
|
||||
// Manual Mode Fields
|
||||
showCarryBoxes?: boolean
|
||||
showAnswerBoxes?: boolean
|
||||
showPlaceValueColors?: boolean
|
||||
showProblemNumbers?: boolean
|
||||
showCellBorder?: boolean
|
||||
showTenFrames?: boolean
|
||||
showTenFramesForAll?: boolean
|
||||
showBorrowNotation?: boolean
|
||||
showBorrowingHints?: boolean
|
||||
manualPreset?: string // Manual mode preset
|
||||
|
||||
// Mastery Mode Fields
|
||||
currentStepId?: string
|
||||
currentAdditionSkillId?: string
|
||||
currentSubtractionSkillId?: string
|
||||
|
||||
// Personalization
|
||||
name: string // Student name
|
||||
fontSize: number // Font size in points
|
||||
|
||||
// Reproducibility (CRITICAL for sharing!)
|
||||
seed: number // Random seed
|
||||
prngAlgorithm: string // PRNG algorithm (e.g., 'mulberry32')
|
||||
}
|
||||
```
|
||||
|
||||
### DERIVED STATE (Calculated)
|
||||
|
||||
These fields are calculated from primary state and should NOT be saved:
|
||||
|
||||
```typescript
|
||||
{
|
||||
total: number // = problemsPerPage × pages
|
||||
rows: number // = Math.ceil(problemsPerPage / cols)
|
||||
}
|
||||
```
|
||||
|
||||
**Why exclude these?**
|
||||
- They're redundant (can be recalculated)
|
||||
- Including them creates risk of inconsistency (e.g., `total: 20` but `pages: 100`)
|
||||
- Primary state is the source of truth
|
||||
|
||||
### EPHEMERAL STATE (Not Persisted)
|
||||
|
||||
These fields are generated fresh at runtime and should NOT be saved:
|
||||
|
||||
```typescript
|
||||
{
|
||||
date: string // Current date (e.g., "January 15, 2025")
|
||||
}
|
||||
```
|
||||
|
||||
**Why exclude?**
|
||||
- Date should reflect when the worksheet is actually generated/printed
|
||||
- User may generate worksheet days/weeks after creating the config
|
||||
|
||||
## Architecture: Blacklist Approach
|
||||
|
||||
### File: `src/app/create/worksheets/utils/extractConfigFields.ts`
|
||||
|
||||
```typescript
|
||||
export function extractConfigFields(formState: WorksheetFormState) {
|
||||
// Blacklist approach: Exclude only derived/ephemeral fields
|
||||
const { rows, total, date, ...persistedFields } = formState
|
||||
|
||||
return {
|
||||
...persistedFields,
|
||||
prngAlgorithm: persistedFields.prngAlgorithm ?? 'mulberry32',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Why Blacklist Instead of Whitelist?
|
||||
|
||||
**Old Approach (FRAGILE):**
|
||||
```typescript
|
||||
// Manually list every field - easy to forget new fields!
|
||||
return {
|
||||
problemsPerPage: formState.problemsPerPage,
|
||||
cols: formState.cols,
|
||||
pages: formState.pages,
|
||||
// ... 30+ fields ...
|
||||
// Oops, forgot to add the new field! Shared worksheets break!
|
||||
}
|
||||
```
|
||||
|
||||
**New Approach (ROBUST):**
|
||||
```typescript
|
||||
// Automatically include everything except derived fields
|
||||
const { rows, total, date, ...persistedFields } = formState
|
||||
return persistedFields
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ New config fields automatically work in shared worksheets
|
||||
- ✅ Only need to update if adding new DERIVED fields (rare)
|
||||
- ✅ Much harder to accidentally break sharing
|
||||
- ✅ Less maintenance burden
|
||||
|
||||
## Persistence Locations
|
||||
|
||||
### 1. localStorage (Auto-Save)
|
||||
|
||||
**Hook:** `src/hooks/useWorksheetAutoSave.ts`
|
||||
|
||||
```typescript
|
||||
const config = extractConfigFields(formState)
|
||||
localStorage.setItem('worksheet-addition-config', JSON.stringify(config))
|
||||
```
|
||||
|
||||
**Purpose:** Restore user's work when they return to the page
|
||||
|
||||
**Restoration:**
|
||||
```typescript
|
||||
const saved = localStorage.getItem('worksheet-addition-config')
|
||||
const config = saved ? JSON.parse(saved) : defaultConfig
|
||||
```
|
||||
|
||||
### 2. Database (Share Links)
|
||||
|
||||
**API Route:** `POST /api/worksheets/share`
|
||||
|
||||
```typescript
|
||||
const config = extractConfigFields(formState)
|
||||
await db.insert(worksheetShares).values({
|
||||
id: shareId,
|
||||
worksheetType: 'addition',
|
||||
config: JSON.stringify(config),
|
||||
})
|
||||
```
|
||||
|
||||
**Purpose:** Allow users to share exact worksheet configurations via URL
|
||||
|
||||
**Restoration:**
|
||||
```typescript
|
||||
const share = await db.query.worksheetShares.findFirst({
|
||||
where: eq(worksheetShares.id, shareId)
|
||||
})
|
||||
const config = JSON.parse(share.config)
|
||||
```
|
||||
|
||||
### 3. API Settings (User Preferences)
|
||||
|
||||
**API Route:** `POST /api/worksheets/settings`
|
||||
|
||||
```typescript
|
||||
const config = extractConfigFields(formState)
|
||||
await db.insert(worksheetSettings).values({
|
||||
userId: session.userId,
|
||||
type: 'addition',
|
||||
config: JSON.stringify(config),
|
||||
})
|
||||
```
|
||||
|
||||
**Purpose:** Save user's preferred defaults (future feature)
|
||||
|
||||
## State Reconstruction Flow
|
||||
|
||||
### When Loading a Shared Worksheet
|
||||
|
||||
1. **Fetch share data:**
|
||||
```typescript
|
||||
const response = await fetch(`/api/worksheets/share/${shareId}`)
|
||||
const { config } = await response.json()
|
||||
```
|
||||
|
||||
2. **Pass to validation:**
|
||||
```typescript
|
||||
const validation = validateWorksheetConfig(config)
|
||||
```
|
||||
|
||||
3. **Validation calculates derived state:**
|
||||
```typescript
|
||||
// In validation.ts
|
||||
const problemsPerPage = formState.problemsPerPage ?? 20
|
||||
const pages = formState.pages ?? 1
|
||||
const total = problemsPerPage * pages // DERIVED!
|
||||
const rows = Math.ceil(total / cols) // DERIVED!
|
||||
```
|
||||
|
||||
4. **Return validated config with derived state:**
|
||||
```typescript
|
||||
return {
|
||||
...persistedFields,
|
||||
total, // Calculated
|
||||
rows, // Calculated
|
||||
date: getDefaultDate(), // Fresh!
|
||||
}
|
||||
```
|
||||
|
||||
## Common Bugs and Solutions
|
||||
|
||||
### Bug: Shared worksheets show wrong page count
|
||||
|
||||
**Cause:** Using `formState.total` as source of truth instead of calculating from `problemsPerPage × pages`
|
||||
|
||||
**Fix:**
|
||||
```typescript
|
||||
// ❌ WRONG - uses fallback when total is missing
|
||||
const total = formState.total ?? 20
|
||||
|
||||
// ✅ CORRECT - calculate from primary state
|
||||
const problemsPerPage = formState.problemsPerPage ?? 20
|
||||
const pages = formState.pages ?? 1
|
||||
const total = problemsPerPage * pages
|
||||
```
|
||||
|
||||
### Bug: New config field doesn't persist
|
||||
|
||||
**Cause (Old):** Forgot to add field to `extractConfigFields` whitelist
|
||||
|
||||
**Solution:** Use blacklist approach - new fields automatically work!
|
||||
|
||||
### Bug: Shared worksheet generates different problems
|
||||
|
||||
**Cause:** Missing `seed` or `prngAlgorithm` in persisted config
|
||||
|
||||
**Solution:** `extractConfigFields` always includes these fields:
|
||||
```typescript
|
||||
const config = {
|
||||
...persistedFields,
|
||||
prngAlgorithm: persistedFields.prngAlgorithm ?? 'mulberry32',
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Config Fields
|
||||
|
||||
### Checklist
|
||||
|
||||
When adding a new config field:
|
||||
|
||||
1. **Determine field category:**
|
||||
- PRIMARY STATE? → No special handling needed! Blacklist approach handles it automatically
|
||||
- DERIVED STATE? → Add to blacklist in `extractConfigFields.ts`
|
||||
- EPHEMERAL STATE? → Add to blacklist in `extractConfigFields.ts`
|
||||
|
||||
2. **Add to type definitions:**
|
||||
```typescript
|
||||
// In config-schemas.ts
|
||||
export const additionConfigV4Schema = z.object({
|
||||
// ... existing fields ...
|
||||
myNewField: z.string().optional(), // Add new field
|
||||
})
|
||||
```
|
||||
|
||||
3. **Update validation defaults (if needed):**
|
||||
```typescript
|
||||
// In validation.ts
|
||||
const myNewField = formState.myNewField ?? 'defaultValue'
|
||||
```
|
||||
|
||||
4. **Test the flow:**
|
||||
- Create worksheet with new field
|
||||
- Save to localStorage
|
||||
- Share the worksheet
|
||||
- Open share link
|
||||
- Verify new field is preserved
|
||||
|
||||
### Example: Adding a New Primary Field
|
||||
|
||||
```typescript
|
||||
// 1. Update schema (config-schemas.ts)
|
||||
export const additionConfigV4Schema = z.object({
|
||||
// ... existing fields ...
|
||||
headerText: z.string().optional(), // New field!
|
||||
})
|
||||
|
||||
// 2. Update validation defaults (validation.ts)
|
||||
const sharedFields = {
|
||||
// ... existing fields ...
|
||||
headerText: formState.headerText ?? 'Math Practice',
|
||||
}
|
||||
|
||||
// 3. Done! extractConfigFields automatically includes it
|
||||
```
|
||||
|
||||
### Example: Adding a New Derived Field
|
||||
|
||||
```typescript
|
||||
// 1. Update schema (config-schemas.ts)
|
||||
// (Derived fields don't go in the persisted schema)
|
||||
|
||||
// 2. Calculate in validation (validation.ts)
|
||||
const averageProblemsPerRow = Math.ceil(problemsPerPage / rows)
|
||||
|
||||
// 3. Add to blacklist (extractConfigFields.ts)
|
||||
const { rows, total, date, averageProblemsPerRow, ...persistedFields } = formState
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Test: Share Link Preservation
|
||||
|
||||
1. Create a worksheet with specific config:
|
||||
- 100 pages
|
||||
- 20 problems per page
|
||||
- 3-4 digit problems
|
||||
- Smart mode with specific display rules
|
||||
|
||||
2. Click "Share" to create share link
|
||||
|
||||
3. Open share link in new incognito window
|
||||
|
||||
4. Verify ALL config matches:
|
||||
- ✅ Total shows 2000 problems (100 × 20)
|
||||
- ✅ Page count shows 100
|
||||
- ✅ Digit range shows 3-4
|
||||
- ✅ Display rules match original
|
||||
- ✅ Problems are identical (same seed)
|
||||
|
||||
### Automated Test (TODO)
|
||||
|
||||
```typescript
|
||||
describe('extractConfigFields', () => {
|
||||
it('excludes derived state', () => {
|
||||
const formState = {
|
||||
problemsPerPage: 20,
|
||||
pages: 5,
|
||||
total: 100, // Should be excluded
|
||||
rows: 5, // Should be excluded
|
||||
}
|
||||
|
||||
const config = extractConfigFields(formState)
|
||||
|
||||
expect(config.problemsPerPage).toBe(20)
|
||||
expect(config.pages).toBe(5)
|
||||
expect(config.total).toBeUndefined()
|
||||
expect(config.rows).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes seed and prngAlgorithm', () => {
|
||||
const formState = {
|
||||
seed: 12345,
|
||||
prngAlgorithm: 'mulberry32',
|
||||
}
|
||||
|
||||
const config = extractConfigFields(formState)
|
||||
|
||||
expect(config.seed).toBe(12345)
|
||||
expect(config.prngAlgorithm).toBe('mulberry32')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- **`src/app/create/worksheets/utils/extractConfigFields.ts`** - Config extraction logic
|
||||
- **`src/app/create/worksheets/validation.ts`** - Config validation and derived state calculation
|
||||
- **`src/app/create/worksheets/types.ts`** - Type definitions (PRIMARY vs DERIVED)
|
||||
- **`src/app/create/worksheets/config-schemas.ts`** - Zod schemas for validation
|
||||
- **`src/hooks/useWorksheetAutoSave.ts`** - Auto-save to localStorage
|
||||
- **`src/app/api/worksheets/share/route.ts`** - Share link creation API
|
||||
- **`src/app/worksheets/shared/[id]/page.tsx`** - Shared worksheet viewer
|
||||
|
||||
## History
|
||||
|
||||
### 2025-01: Blacklist Refactor
|
||||
|
||||
**Problem:** Multiple incidents where new config fields weren't shared correctly because we forgot to update the extraction whitelist.
|
||||
|
||||
**Solution:** Refactored `extractConfigFields` to use blacklist approach (exclude derived fields) instead of whitelist (manually include everything).
|
||||
|
||||
**Result:** New config fields now automatically work in shared worksheets without touching extraction code.
|
||||
|
||||
### 2025-01: Total Calculation Bug
|
||||
|
||||
**Problem:** Shared 100-page worksheets displayed as 4 pages because validation defaulted `total` to 20 instead of calculating from `problemsPerPage × pages`.
|
||||
|
||||
**Solution:** Calculate `total` from primary state instead of using fallback:
|
||||
```typescript
|
||||
// Before (bug)
|
||||
const total = formState.total ?? 20
|
||||
|
||||
// After (fix)
|
||||
const total = problemsPerPage * pages
|
||||
```
|
||||
|
||||
**Root Cause:** `extractConfigFields` didn't save `total` (correctly, as it's derived), but validation incorrectly treated it as primary state.
|
||||
|
|
@ -30,7 +30,12 @@ const config: StorybookConfig = {
|
|||
if (config.resolve) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
// Map styled-system imports to the actual directory
|
||||
// Map @styled/* imports (from tsconfig paths)
|
||||
'@styled/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||
'@styled/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||
'@styled/jsx': join(__dirname, '../styled-system/jsx/index.mjs'),
|
||||
'@styled/recipes': join(__dirname, '../styled-system/recipes/index.mjs'),
|
||||
// Map relative styled-system imports
|
||||
'../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||
'../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'),
|
||||
'../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { Preview } from '@storybook/nextjs'
|
||||
import React from 'react'
|
||||
import { ThemeProvider } from '../src/contexts/ThemeContext'
|
||||
import '../styled-system/styles.css'
|
||||
|
||||
const preview: Preview = {
|
||||
|
|
@ -10,6 +12,13 @@ const preview: Preview = {
|
|||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThemeProvider>
|
||||
<Story />
|
||||
</ThemeProvider>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default preview
|
||||
|
|
@ -228,6 +228,39 @@ export default defineConfig({
|
|||
boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
},
|
||||
// Wiggle - playful rotation oscillation
|
||||
wiggle: {
|
||||
'0%, 100%': { transform: 'rotate(0deg)' },
|
||||
'25%': { transform: 'rotate(-3deg)' },
|
||||
'75%': { transform: 'rotate(3deg)' },
|
||||
},
|
||||
// Shimmer - opacity wave for loading states
|
||||
shimmer: {
|
||||
'0%, 100%': { opacity: '0.7' },
|
||||
'50%': { opacity: '0.4' },
|
||||
},
|
||||
// Fade in with scale - entrance animation
|
||||
fadeInScale: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.9)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
// Slide in from right - entrance animation
|
||||
slideInRight: {
|
||||
'0%': { transform: 'translateX(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
// Morph width - bars dramatically expand and contract (simulating numbers changing)
|
||||
morphWidth: {
|
||||
'0%, 100%': { transform: 'scaleX(1)', opacity: '0.7' },
|
||||
'25%': { transform: 'scaleX(0.5)', opacity: '0.4' },
|
||||
'50%': { transform: 'scaleX(1.3)', opacity: '0.9' },
|
||||
'75%': { transform: 'scaleX(0.7)', opacity: '0.5' },
|
||||
},
|
||||
// Color shift - operators cycle through hues
|
||||
colorShift: {
|
||||
'0%, 100%': { filter: 'hue-rotate(0deg)' },
|
||||
'50%': { filter: 'hue-rotate(20deg)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
# Worksheet Config Persistence - Quick Reference
|
||||
|
||||
## TL;DR
|
||||
|
||||
**When adding a new config field:**
|
||||
- ✅ **Do nothing special!** The blacklist approach auto-includes new fields
|
||||
- ✅ Only update if adding a **DERIVED** field (exclude it in `extractConfigFields.ts`)
|
||||
- ✅ Add defaults in `validation.ts` if needed
|
||||
- ✅ Test: Create → Share → Open share link → Verify field persists
|
||||
|
||||
## Field Categories
|
||||
|
||||
```typescript
|
||||
// PRIMARY STATE (auto-persisted)
|
||||
problemsPerPage: 20
|
||||
pages: 5
|
||||
cols: 4
|
||||
// ... all other config fields
|
||||
|
||||
// DERIVED STATE (excluded from persistence)
|
||||
total: 100 // = problemsPerPage × pages
|
||||
rows: 5 // = Math.ceil(problemsPerPage / cols)
|
||||
|
||||
// EPHEMERAL STATE (excluded from persistence)
|
||||
date: "Jan 15" // Generated fresh at render time
|
||||
```
|
||||
|
||||
## Architecture Files
|
||||
|
||||
```
|
||||
src/app/create/worksheets/
|
||||
├── utils/
|
||||
│ └── extractConfigFields.ts ← Blacklist: excludes rows, total, date
|
||||
├── validation.ts ← Calculates derived state from primary
|
||||
├── types.ts ← Documents field categories
|
||||
└── README_CONFIG_PERSISTENCE.md ← This file
|
||||
|
||||
.claude/
|
||||
└── WORKSHEET_CONFIG_PERSISTENCE.md ← Full architecture doc
|
||||
```
|
||||
|
||||
## Key Functions
|
||||
|
||||
### `extractConfigFields(formState)`
|
||||
**What it does:** Prepares config for saving to localStorage/database
|
||||
**How it works:** Excludes only `rows`, `total`, `date` (blacklist approach)
|
||||
**Returns:** Config object with all primary state fields
|
||||
|
||||
```typescript
|
||||
// Usage in ShareModal
|
||||
const config = extractConfigFields(formState)
|
||||
await fetch('/api/worksheets/share', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ worksheetType: 'addition', config })
|
||||
})
|
||||
```
|
||||
|
||||
### `validateWorksheetConfig(formState)`
|
||||
**What it does:** Validates config and calculates derived state
|
||||
**How it works:** Calculates `total = problemsPerPage × pages`, `rows = Math.ceil(problemsPerPage / cols)`
|
||||
**Returns:** Validated config with both primary AND derived state
|
||||
|
||||
```typescript
|
||||
// Usage when loading shared worksheets
|
||||
const validation = validateWorksheetConfig(loadedConfig)
|
||||
if (!validation.isValid) {
|
||||
console.error('Invalid config:', validation.errors)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Adding a New Field
|
||||
|
||||
```typescript
|
||||
// 1. Update schema (config-schemas.ts)
|
||||
export const additionConfigV4Schema = z.object({
|
||||
// ... existing fields
|
||||
myNewField: z.string().optional(),
|
||||
})
|
||||
|
||||
// 2. Update validation defaults (validation.ts)
|
||||
const sharedFields = {
|
||||
// ... existing fields
|
||||
myNewField: formState.myNewField ?? 'default',
|
||||
}
|
||||
|
||||
// 3. Done! extractConfigFields auto-includes it
|
||||
```
|
||||
|
||||
### Pattern 2: Adding a Derived Field
|
||||
|
||||
```typescript
|
||||
// 1. Calculate in validation.ts
|
||||
const myDerivedValue = problemsPerPage / cols
|
||||
|
||||
// 2. Add to blacklist (extractConfigFields.ts)
|
||||
const { rows, total, date, myDerivedValue, ...persistedFields } = formState
|
||||
|
||||
// 3. Document in types.ts
|
||||
export type WorksheetFormState = /* ... */ & {
|
||||
/** Derived: myDerivedValue = problemsPerPage / cols */
|
||||
myDerivedValue?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Common Bugs
|
||||
|
||||
### Bug: "My new field doesn't persist when shared"
|
||||
**Old Cause:** Forgot to add field to whitelist
|
||||
**Current:** Should auto-work with blacklist approach!
|
||||
**Check:** Is the field derived/ephemeral? If yes, should it be excluded?
|
||||
|
||||
### Bug: "Shared worksheets show wrong page count"
|
||||
**Cause:** Using `formState.total` instead of calculating from primary state
|
||||
**Fix:** Always calculate: `total = problemsPerPage × pages`
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
const total = formState.total ?? 20
|
||||
|
||||
// ✅ CORRECT
|
||||
const problemsPerPage = formState.problemsPerPage ?? 20
|
||||
const pages = formState.pages ?? 1
|
||||
const total = problemsPerPage * pages
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
When adding/modifying config fields:
|
||||
|
||||
- [ ] Create worksheet with new field
|
||||
- [ ] Save config (auto-save triggers)
|
||||
- [ ] Refresh page
|
||||
- [ ] Verify field restored from localStorage
|
||||
- [ ] Click "Share" to create share link
|
||||
- [ ] Open share link in incognito window
|
||||
- [ ] Verify field persists in shared worksheet
|
||||
- [ ] Check console for extraction logs
|
||||
|
||||
## Debug Logs
|
||||
|
||||
Enable these console logs to debug config persistence:
|
||||
|
||||
```typescript
|
||||
// In extractConfigFields.ts
|
||||
console.log('[extractConfigFields] Extracted config:', {
|
||||
fieldCount: Object.keys(config).length,
|
||||
seed: config.seed,
|
||||
pages: config.pages,
|
||||
problemsPerPage: config.problemsPerPage,
|
||||
})
|
||||
|
||||
// In validation.ts
|
||||
console.log('[validateWorksheetConfig] PRIMARY → DERIVED state:', {
|
||||
problemsPerPage,
|
||||
pages,
|
||||
total,
|
||||
hadTotal: formState.total !== undefined,
|
||||
})
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Full architecture:** `.claude/WORKSHEET_CONFIG_PERSISTENCE.md`
|
||||
- **Inline docs:** `extractConfigFields.ts`, `validation.ts`, `types.ts`
|
||||
- **Share creation:** `src/app/api/worksheets/share/route.ts`
|
||||
- **Share loading:** `src/app/worksheets/shared/[id]/page.tsx`
|
||||
|
||||
## Questions?
|
||||
|
||||
If you encounter config persistence issues:
|
||||
|
||||
1. Check console logs for extraction/validation
|
||||
2. Verify field category (PRIMARY vs DERIVED vs EPHEMERAL)
|
||||
3. Read full architecture doc: `.claude/WORKSHEET_CONFIG_PERSISTENCE.md`
|
||||
4. Check git history for `extractConfigFields.ts` - look for similar fixes
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { ConfigSidebar } from './ConfigSidebar'
|
||||
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
|
||||
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
||||
import { PagePlaceholder } from './PagePlaceholder'
|
||||
import type { WorksheetFormState } from '../types'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Complete/Full Worksheet Generator',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Mock preview component that simulates virtual loading without API calls
|
||||
function MockPreviewWithVirtualLoading({
|
||||
pages = 5,
|
||||
formState,
|
||||
}: {
|
||||
pages?: number
|
||||
formState: WorksheetFormState
|
||||
}) {
|
||||
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
background: '#e5e7eb',
|
||||
padding: '20px',
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const container = e.currentTarget
|
||||
const scrollTop = container.scrollTop
|
||||
const clientHeight = container.clientHeight
|
||||
|
||||
// Simulate loading pages as they come into view
|
||||
const pageHeight = 1104 // 1056px + 48px gap
|
||||
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||
|
||||
const newLoadedPages = new Set(loadedPages)
|
||||
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, pages - 1); i++) {
|
||||
if (!newLoadedPages.has(i)) {
|
||||
newLoadedPages.add(i)
|
||||
// Simulate async loading delay
|
||||
setTimeout(() => {
|
||||
setLoadedPages((prev) => new Set([...prev, i]))
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}>
|
||||
{Array.from({ length: pages }).map((_, index) => {
|
||||
const orientation = formState.orientation || 'portrait'
|
||||
const maxWidth = orientation === 'portrait' ? '816px' : '1056px'
|
||||
|
||||
return (
|
||||
<div key={index} style={{ width: '100%', maxWidth }}>
|
||||
<PagePlaceholder
|
||||
pageNumber={index + 1}
|
||||
orientation={orientation}
|
||||
rows={Math.ceil((formState.problemsPerPage || 20) / (formState.cols || 4))}
|
||||
cols={formState.cols || 4}
|
||||
loading={!loadedPages.has(index)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mockFormState: WorksheetFormState = {
|
||||
operator: 'addition',
|
||||
mode: 'manual',
|
||||
digitRange: { min: 2, max: 3 },
|
||||
problemsPerPage: 20,
|
||||
pages: 5,
|
||||
cols: 4,
|
||||
orientation: 'portrait',
|
||||
name: 'Student Name',
|
||||
displayRules: {
|
||||
tenFrames: 'sometimes',
|
||||
carryBoxes: 'sometimes',
|
||||
placeValueColors: 'sometimes',
|
||||
answerBoxes: 'always',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
pAnyStart: 0.3,
|
||||
pAllStart: 0.1,
|
||||
interpolate: false,
|
||||
seed: 12345,
|
||||
}
|
||||
|
||||
function FullWorksheetGenerator({ initialState }: { initialState?: Partial<WorksheetFormState> }) {
|
||||
const [formState, setFormState] = useState<WorksheetFormState>({
|
||||
...mockFormState,
|
||||
...initialState,
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
|
||||
const updateFormState = (updates: Partial<WorksheetFormState>) => {
|
||||
setFormState((prev) => ({ ...prev, ...updates }))
|
||||
|
||||
// Simulate auto-save
|
||||
setIsSaving(true)
|
||||
setTimeout(() => {
|
||||
setIsSaving(false)
|
||||
setLastSaved(new Date())
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<ResponsivePanelLayout
|
||||
config={formState}
|
||||
sidebarContent={<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />}
|
||||
previewContent={
|
||||
<MockPreviewWithVirtualLoading formState={formState} pages={formState.pages} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</WorksheetConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const CompleteGenerator: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#eff6ff',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '800px',
|
||||
}}
|
||||
>
|
||||
<strong>🎨 Complete Worksheet Studio:</strong> This is the full worksheet generator
|
||||
interface with all features. Try adjusting settings in the sidebar and watch the preview
|
||||
update. Resize the panels by dragging the divider. Scroll through pages to see virtual
|
||||
loading in action.
|
||||
</div>
|
||||
<FullWorksheetGenerator />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const VirtualLoadingDemo: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#fef3c7',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '800px',
|
||||
}}
|
||||
>
|
||||
<strong>⚡ Virtual Loading:</strong> This worksheet has 20 pages (400 problems). Notice
|
||||
how only the first 3 pages load initially. As you scroll down in the preview panel, pages
|
||||
load on-demand with a loading spinner, then display content. This keeps the app fast even
|
||||
with hundreds of problems!
|
||||
</div>
|
||||
<FullWorksheetGenerator initialState={{ pages: 20 }} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ExtremeScaleTest: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#fee2e2',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '800px',
|
||||
}}
|
||||
>
|
||||
<strong>🚀 Extreme Scale:</strong> 50 pages × 20 problems = 1,000 problems! Virtual
|
||||
loading + React Query caching make this performant. Only visible pages are loaded and
|
||||
rendered. Scrolling remains smooth throughout.
|
||||
</div>
|
||||
<FullWorksheetGenerator initialState={{ pages: 50 }} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AdditionWorksheet: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
operator: 'addition',
|
||||
mode: 'manual',
|
||||
digitRange: { min: 2, max: 3 },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const SubtractionWorksheet: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
operator: 'subtraction',
|
||||
mode: 'manual',
|
||||
digitRange: { min: 2, max: 3 },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const MixedOperations: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
operator: 'mixed',
|
||||
mode: 'manual',
|
||||
digitRange: { min: 2, max: 3 },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const SmartModeEarlyLearner: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
mode: 'smart',
|
||||
difficultyProfile: 'earlyLearner',
|
||||
pages: 3,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const SmartModeAdvanced: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
mode: 'smart',
|
||||
difficultyProfile: 'advanced',
|
||||
pages: 5,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const LandscapeLayout: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
orientation: 'landscape',
|
||||
cols: 5,
|
||||
problemsPerPage: 25,
|
||||
pages: 4,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const DenseLayout: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
problemsPerPage: 40,
|
||||
cols: 5,
|
||||
orientation: 'landscape',
|
||||
pages: 3,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const SparseLayout: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
problemsPerPage: 10,
|
||||
cols: 2,
|
||||
pages: 3,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const SinglePageWorksheet: Story = {
|
||||
render: () => (
|
||||
<FullWorksheetGenerator
|
||||
initialState={{
|
||||
pages: 1,
|
||||
problemsPerPage: 20,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const ResponsiveDemo: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#eff6ff',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '800px',
|
||||
}}
|
||||
>
|
||||
<strong>📱 Responsive Layout:</strong> On desktop, you see resizable panels (drag the
|
||||
divider!). On mobile, the config panel becomes a drawer accessible via a floating button.
|
||||
Try resizing your browser window to see the layout adapt.
|
||||
</div>
|
||||
<FullWorksheetGenerator />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AutoSaveDemo: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#d1fae5',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '800px',
|
||||
}}
|
||||
>
|
||||
<strong>💾 Auto-Save:</strong> Watch the top-right corner of the sidebar as you change
|
||||
settings. You'll see "Saving..." appear briefly, then "✓ Saved" when complete. Settings
|
||||
are automatically saved to localStorage so you can return to your work later.
|
||||
</div>
|
||||
<FullWorksheetGenerator />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ArchitectureOverview: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '20px',
|
||||
gap: '20px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '24px',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '1000px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 20px 0', fontSize: '24px', fontWeight: 600 }}>
|
||||
Worksheet Generator Architecture
|
||||
</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '20px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||
🏗️ Component Hierarchy
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||
<li>AdditionWorksheetClient (top-level)</li>
|
||||
<li style={{ marginLeft: '20px' }}>ResponsivePanelLayout</li>
|
||||
<li style={{ marginLeft: '40px' }}>ConfigSidebar</li>
|
||||
<li style={{ marginLeft: '60px' }}>TabNavigation</li>
|
||||
<li style={{ marginLeft: '60px' }}>ContentTab / LayoutTab / etc.</li>
|
||||
<li style={{ marginLeft: '40px' }}>PreviewCenter</li>
|
||||
<li style={{ marginLeft: '60px' }}>WorksheetPreview</li>
|
||||
<li style={{ marginLeft: '80px' }}>PagePlaceholder (virtual)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||
⚡ Key Technologies
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||
<li>React Query (caching, pagination)</li>
|
||||
<li>Intersection Observer (virtualization)</li>
|
||||
<li>react-resizable-panels (desktop layout)</li>
|
||||
<li>Context API (form state management)</li>
|
||||
<li>localStorage (auto-save persistence)</li>
|
||||
<li>sessionStorage (tab selection)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||
📦 Virtual Loading System
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||
<li>Initial batch: 3 pages</li>
|
||||
<li>Triggers at 50% viewport distance</li>
|
||||
<li>Preloads adjacent pages (±1)</li>
|
||||
<li>Groups consecutive pages into batch requests</li>
|
||||
<li>React Query deduplication</li>
|
||||
<li>Handles 50+ pages smoothly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: '16px', fontWeight: 600 }}>
|
||||
💾 State Management
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#6b7280' }}>
|
||||
<li>formState (immediate updates)</li>
|
||||
<li>debouncedFormState (preview updates)</li>
|
||||
<li>Auto-save to localStorage (2s debounce)</li>
|
||||
<li>Query cache invalidation on config change</li>
|
||||
<li>Optimistic UI updates</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<strong>🎯 Performance Optimization:</strong>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#3b82f6' }}>
|
||||
Virtual loading + React Query caching + debounced updates + Intersection Observer =
|
||||
Smooth UX even with 1,000+ problems. Only visible pages are rendered, adjacent pages
|
||||
are preloaded, and fetched data is cached for instant scrolling back.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: '600px',
|
||||
maxWidth: '1000px',
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<FullWorksheetGenerator initialState={{ pages: 15 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { ConfigSidebar } from './ConfigSidebar'
|
||||
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
||||
import type { WorksheetFormState } from '../types'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Config Panel/ConfigSidebar',
|
||||
component: ConfigSidebar,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ConfigSidebar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const mockFormState: WorksheetFormState = {
|
||||
operator: 'addition',
|
||||
mode: 'manual',
|
||||
digitRange: { min: 2, max: 3 },
|
||||
problemsPerPage: 20,
|
||||
pages: 5,
|
||||
cols: 4,
|
||||
orientation: 'portrait',
|
||||
name: 'Student Name',
|
||||
displayRules: {
|
||||
tenFrames: 'sometimes',
|
||||
carryBoxes: 'sometimes',
|
||||
placeValueColors: 'sometimes',
|
||||
answerBoxes: 'always',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
pAnyStart: 0.3,
|
||||
pAllStart: 0.1,
|
||||
interpolate: false,
|
||||
seed: 12345,
|
||||
}
|
||||
|
||||
// Wrapper to provide context
|
||||
function SidebarWrapper({
|
||||
initialState,
|
||||
isSaving = false,
|
||||
lastSaved = null,
|
||||
isReadOnly = false,
|
||||
}: {
|
||||
initialState?: Partial<WorksheetFormState>
|
||||
isSaving?: boolean
|
||||
lastSaved?: Date | null
|
||||
isReadOnly?: boolean
|
||||
}) {
|
||||
const [formState, setFormState] = useState<WorksheetFormState>({
|
||||
...mockFormState,
|
||||
...initialState,
|
||||
})
|
||||
|
||||
const updateFormState = (updates: Partial<WorksheetFormState>) => {
|
||||
setFormState((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '400px', display: 'flex', flexDirection: 'column' }}>
|
||||
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||
<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} isReadOnly={isReadOnly} />
|
||||
</WorksheetConfigProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DefaultState: Story = {
|
||||
render: () => <SidebarWrapper />,
|
||||
}
|
||||
|
||||
export const Saving: Story = {
|
||||
render: () => <SidebarWrapper isSaving={true} />,
|
||||
}
|
||||
|
||||
export const Saved: Story = {
|
||||
render: () => <SidebarWrapper lastSaved={new Date()} />,
|
||||
}
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
render: () => <SidebarWrapper isReadOnly={true} />,
|
||||
}
|
||||
|
||||
export const AdditionMode: Story = {
|
||||
render: () => <SidebarWrapper initialState={{ operator: 'addition' }} />,
|
||||
}
|
||||
|
||||
export const SubtractionMode: Story = {
|
||||
render: () => <SidebarWrapper initialState={{ operator: 'subtraction' }} />,
|
||||
}
|
||||
|
||||
export const MixedMode: Story = {
|
||||
render: () => <SidebarWrapper initialState={{ operator: 'mixed' }} />,
|
||||
}
|
||||
|
||||
export const SmartMode: Story = {
|
||||
render: () => (
|
||||
<SidebarWrapper
|
||||
initialState={{
|
||||
mode: 'smart',
|
||||
difficultyProfile: 'earlyLearner',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const MasteryMode: Story = {
|
||||
render: () => (
|
||||
<SidebarWrapper
|
||||
initialState={{
|
||||
mode: 'mastery',
|
||||
currentAdditionSkillId: 'add-2digit-no-regroup',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const LandscapeLayout: Story = {
|
||||
render: () => (
|
||||
<SidebarWrapper
|
||||
initialState={{
|
||||
orientation: 'landscape',
|
||||
cols: 5,
|
||||
problemsPerPage: 25,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const InteractiveTabs: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', gap: '20px', padding: '20px' }}>
|
||||
<div style={{ width: '400px' }}>
|
||||
<SidebarWrapper />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600 }}>
|
||||
Tab Navigation
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
|
||||
<div>
|
||||
<strong>📝 Content Tab:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Choose operator (Addition, Subtraction, or Mixed) and select mode (Manual, Smart, or
|
||||
Mastery).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>📐 Layout Tab:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Configure page orientation, problems per page, number of pages, and column layout.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>🎯 Scaffolding Tab:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Control when visual aids appear (ten-frames, carry boxes, place value colors, etc.).
|
||||
Set rules like "always", "never", or "when regrouping".
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>⚡ Difficulty Tab:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Adjust regrouping frequency, difficulty presets, and progressive difficulty
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
<strong>💡 Pro Tip:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#3b82f6', fontSize: '13px' }}>
|
||||
Active tab is saved to sessionStorage. When you return, the same tab will be
|
||||
selected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AllTabsShowcase: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '20px',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
{['operator', 'layout', 'scaffolding', 'difficulty'].map((tab) => {
|
||||
// Force tab by manipulating sessionStorage before mount
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('worksheet-config-active-tab', tab)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={tab} style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div
|
||||
style={{
|
||||
background: '#1f2937',
|
||||
color: 'white',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px 6px 0 0',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{tab} Tab
|
||||
</div>
|
||||
<div style={{ flex: 1, border: '2px solid #1f2937', borderTop: 'none' }}>
|
||||
<SidebarWrapper />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ComparisonReadOnlyVsEditable: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', gap: '20px', padding: '20px' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div
|
||||
style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
✏️ Editable
|
||||
</div>
|
||||
<div style={{ flex: 1, border: '2px solid #3b82f6', borderTop: 'none' }}>
|
||||
<SidebarWrapper isReadOnly={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div
|
||||
style={{
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
👁️ Read-Only
|
||||
</div>
|
||||
<div style={{ flex: 1, border: '2px solid #6b7280', borderTop: 'none' }}>
|
||||
<SidebarWrapper isReadOnly={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const DenseProblemsLayout: Story = {
|
||||
render: () => (
|
||||
<SidebarWrapper
|
||||
initialState={{
|
||||
problemsPerPage: 40,
|
||||
cols: 5,
|
||||
orientation: 'landscape',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const MinimalProblems: Story = {
|
||||
render: () => (
|
||||
<SidebarWrapper
|
||||
initialState={{
|
||||
problemsPerPage: 6,
|
||||
cols: 2,
|
||||
pages: 1,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithStudentName: Story = {
|
||||
render: () => <SidebarWrapper initialState={{ name: 'Alex Martinez' }} />,
|
||||
}
|
||||
|
||||
export const EmptyStudentName: Story = {
|
||||
render: () => <SidebarWrapper initialState={{ name: '' }} />,
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { PagePlaceholder } from './PagePlaceholder'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Virtual Loading/PagePlaceholder',
|
||||
component: PagePlaceholder,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PagePlaceholder>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Portrait: Story = {
|
||||
args: {
|
||||
pageNumber: 1,
|
||||
orientation: 'portrait',
|
||||
rows: 5,
|
||||
cols: 4,
|
||||
loading: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Landscape: Story = {
|
||||
args: {
|
||||
pageNumber: 1,
|
||||
orientation: 'landscape',
|
||||
rows: 4,
|
||||
cols: 5,
|
||||
loading: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const LoadingState: Story = {
|
||||
args: {
|
||||
pageNumber: 5,
|
||||
orientation: 'portrait',
|
||||
rows: 5,
|
||||
cols: 4,
|
||||
loading: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const LoadingLandscape: Story = {
|
||||
args: {
|
||||
pageNumber: 8,
|
||||
orientation: 'landscape',
|
||||
rows: 4,
|
||||
cols: 5,
|
||||
loading: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const TwoColumns: Story = {
|
||||
args: {
|
||||
pageNumber: 2,
|
||||
orientation: 'portrait',
|
||||
rows: 10,
|
||||
cols: 2,
|
||||
loading: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const FiveColumns: Story = {
|
||||
args: {
|
||||
pageNumber: 3,
|
||||
orientation: 'landscape',
|
||||
rows: 3,
|
||||
cols: 5,
|
||||
loading: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const VirtualScrollSimulation: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '48px',
|
||||
padding: '20px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '16px', fontSize: '16px', fontWeight: 600 }}>
|
||||
Virtual Scroll: Multi-Page Worksheet Loading
|
||||
</h3>
|
||||
<p
|
||||
style={{ marginBottom: '24px', fontSize: '13px', color: '#6b7280', maxWidth: '600px' }}
|
||||
>
|
||||
This demonstrates how placeholder pages appear during virtual scrolling. Pages load
|
||||
on-demand as they become visible in the viewport, showing a loading state while the SVG
|
||||
is being generated and fetched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Page 1 - Already loaded */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', fontWeight: 600, color: '#059669' }}>
|
||||
✓ Loaded
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: '2px solid #10b981',
|
||||
borderRadius: '8px',
|
||||
padding: '8px',
|
||||
background: '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
<PagePlaceholder
|
||||
pageNumber={1}
|
||||
orientation="portrait"
|
||||
rows={5}
|
||||
cols={4}
|
||||
loading={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page 2 - Loading */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', fontWeight: 600, color: '#f59e0b' }}>
|
||||
⏳ Loading...
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: '2px solid #f59e0b',
|
||||
borderRadius: '8px',
|
||||
padding: '8px',
|
||||
background: '#fffbeb',
|
||||
}}
|
||||
>
|
||||
<PagePlaceholder
|
||||
pageNumber={2}
|
||||
orientation="portrait"
|
||||
rows={5}
|
||||
cols={4}
|
||||
loading={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page 3 - Not yet visible */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', fontWeight: 600, color: '#6b7280' }}>
|
||||
⏸ Not yet loaded
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: '2px solid #d1d5db',
|
||||
borderRadius: '8px',
|
||||
padding: '8px',
|
||||
background: '#f9fafb',
|
||||
}}
|
||||
>
|
||||
<PagePlaceholder
|
||||
pageNumber={3}
|
||||
orientation="portrait"
|
||||
rows={5}
|
||||
cols={4}
|
||||
loading={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const DenseLayout: Story = {
|
||||
args: {
|
||||
pageNumber: 1,
|
||||
orientation: 'portrait',
|
||||
rows: 8,
|
||||
cols: 3,
|
||||
loading: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const SparseLayout: Story = {
|
||||
args: {
|
||||
pageNumber: 1,
|
||||
orientation: 'landscape',
|
||||
rows: 2,
|
||||
cols: 4,
|
||||
loading: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const ComparisonView: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '48px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Portrait</h4>
|
||||
<p style={{ marginBottom: '16px', fontSize: '12px', color: '#6b7280' }}>8.5" × 11"</p>
|
||||
<PagePlaceholder
|
||||
pageNumber={1}
|
||||
orientation="portrait"
|
||||
rows={5}
|
||||
cols={4}
|
||||
loading={false}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Landscape</h4>
|
||||
<p style={{ marginBottom: '16px', fontSize: '12px', color: '#6b7280' }}>11" × 8.5"</p>
|
||||
<PagePlaceholder
|
||||
pageNumber={1}
|
||||
orientation="landscape"
|
||||
rows={4}
|
||||
cols={5}
|
||||
loading={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const LoadingAnimation: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<strong>ℹ️ Loading Behavior:</strong>
|
||||
<p style={{ margin: '8px 0 0 0' }}>
|
||||
Notice the spinning hourglass emoji and "Loading page X..." text. The entire placeholder
|
||||
pulses to indicate active loading. This appears when pages are being fetched from the
|
||||
API.
|
||||
</p>
|
||||
</div>
|
||||
<PagePlaceholder pageNumber={7} orientation="portrait" rows={5} cols={4} loading={true} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { css } from '@styled/css'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface PagePlaceholderProps {
|
||||
pageNumber: number
|
||||
|
|
@ -11,6 +12,293 @@ interface PagePlaceholderProps {
|
|||
loading?: boolean
|
||||
}
|
||||
|
||||
interface ProblemVariation {
|
||||
topWidths: [number, number]
|
||||
bottomWidths: [number, number]
|
||||
operator: '+' | '−'
|
||||
}
|
||||
|
||||
interface AnimatedProblemCellProps {
|
||||
seed: number
|
||||
isDark: boolean
|
||||
animationDelay: string
|
||||
wiggleDelay: string
|
||||
}
|
||||
|
||||
function AnimatedProblemCell({
|
||||
seed,
|
||||
isDark,
|
||||
animationDelay,
|
||||
wiggleDelay,
|
||||
}: AnimatedProblemCellProps) {
|
||||
// Generate multiple problem variations to cycle through
|
||||
const problems: ProblemVariation[] = [
|
||||
{
|
||||
topWidths: [55 + ((seed * 7) % 25), 50 + ((seed * 11) % 30)],
|
||||
bottomWidths: [55 + ((seed * 13) % 25), 50 + ((seed * 19) % 30)],
|
||||
operator: '+',
|
||||
},
|
||||
{
|
||||
topWidths: [45 + ((seed * 17) % 30), 60 + ((seed * 23) % 20)],
|
||||
bottomWidths: [40 + ((seed * 29) % 35), 55 + ((seed * 31) % 25)],
|
||||
operator: '−',
|
||||
},
|
||||
{
|
||||
topWidths: [65 + ((seed * 37) % 15), 45 + ((seed * 41) % 30)],
|
||||
bottomWidths: [50 + ((seed * 43) % 30), 60 + ((seed * 47) % 20)],
|
||||
operator: '+',
|
||||
},
|
||||
]
|
||||
|
||||
const showCarryBoxes = seed % 4 === 0
|
||||
|
||||
// State to track which problem variation is currently shown
|
||||
const [currentProblemIndex, setCurrentProblemIndex] = useState(0)
|
||||
|
||||
// Cycle through problems with staggered timing based on seed
|
||||
useEffect(() => {
|
||||
// Each cell cycles at a slightly different rate (0.5-1 seconds) - super fast!
|
||||
const cycleInterval = 500 + (seed % 10) * 50
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setCurrentProblemIndex((prev) => (prev + 1) % problems.length)
|
||||
}, cycleInterval)
|
||||
|
||||
return () => clearInterval(intervalId)
|
||||
}, [seed, problems.length])
|
||||
|
||||
const currentProblem = problems[currentProblemIndex]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
animationDelay: animationDelay,
|
||||
}}
|
||||
className={css({
|
||||
flex: 1,
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
border: '3px dashed',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
||||
rounded: 'lg',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '4',
|
||||
gap: '3',
|
||||
position: 'relative',
|
||||
justifyContent: 'space-between',
|
||||
animation: 'fadeInScale 0.6s ease-out',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
transform: 'scale(1.02)',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.500',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Top section: problem number + optional carry boxes */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
minHeight: '16px',
|
||||
})}
|
||||
>
|
||||
{/* Problem number - bouncy circle */}
|
||||
<div
|
||||
style={{ animationDelay: wiggleDelay }}
|
||||
className={css({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
bg: isDark ? 'blue.500' : 'blue.500',
|
||||
rounded: 'full',
|
||||
opacity: 0.85,
|
||||
animation: 'bounce 2s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Optional carry boxes - wiggle animation */}
|
||||
{showCarryBoxes && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '3px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{ animationDelay: wiggleDelay }}
|
||||
className={css({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '3px dashed',
|
||||
borderColor: isDark ? 'purple.400' : 'purple.500',
|
||||
rounded: 'sm',
|
||||
opacity: 0.75,
|
||||
animation: 'wiggle 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
style={{ animationDelay: `${parseFloat(wiggleDelay) + 0.2}s` }}
|
||||
className={css({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '3px dashed',
|
||||
borderColor: isDark ? 'purple.400' : 'purple.500',
|
||||
rounded: 'sm',
|
||||
opacity: 0.75,
|
||||
animation: 'wiggle 1.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Middle section: cartoonish number representations */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
})}
|
||||
>
|
||||
{/* Top operand - bars with widths from current problem */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
alignItems: 'flex-end',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
key={`top1-${currentProblemIndex}`}
|
||||
style={{ animationDelay: animationDelay }}
|
||||
className={css({
|
||||
width: `${currentProblem.topWidths[0]}%`,
|
||||
height: '14px',
|
||||
bg: isDark ? 'cyan.400' : 'cyan.500',
|
||||
rounded: 'full',
|
||||
opacity: 0.9,
|
||||
transition: 'width 0.4s ease-in-out',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
key={`top2-${currentProblemIndex}`}
|
||||
style={{ animationDelay: `${parseFloat(animationDelay) + 0.2}s` }}
|
||||
className={css({
|
||||
width: `${currentProblem.topWidths[1]}%`,
|
||||
height: '14px',
|
||||
bg: isDark ? 'cyan.400' : 'cyan.500',
|
||||
rounded: 'full',
|
||||
opacity: 0.9,
|
||||
transition: 'width 0.4s ease-in-out',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Operator + bottom operand - playful layout */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
alignSelf: 'flex-end',
|
||||
width: '75%',
|
||||
})}
|
||||
>
|
||||
{/* Operator symbol - cycles between + and − */}
|
||||
<div
|
||||
key={`op-${currentProblemIndex}`}
|
||||
style={{ animationDelay: `${parseFloat(wiggleDelay) + 0.1}s` }}
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: 'black',
|
||||
color: isDark ? 'orange.400' : 'orange.600',
|
||||
opacity: 0.95,
|
||||
animation: 'colorShift 2s ease-in-out infinite',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '20px',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
})}
|
||||
>
|
||||
{currentProblem.operator}
|
||||
</div>
|
||||
{/* Bottom operand bars - widths from current problem */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
key={`bottom1-${currentProblemIndex}`}
|
||||
style={{ animationDelay: `${parseFloat(animationDelay) + 0.3}s` }}
|
||||
className={css({
|
||||
width: `${currentProblem.bottomWidths[0]}%`,
|
||||
height: '14px',
|
||||
bg: isDark ? 'green.400' : 'green.500',
|
||||
rounded: 'full',
|
||||
opacity: 0.9,
|
||||
alignSelf: 'flex-end',
|
||||
transition: 'width 0.4s ease-in-out',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
key={`bottom2-${currentProblemIndex}`}
|
||||
style={{ animationDelay: `${parseFloat(animationDelay) + 0.4}s` }}
|
||||
className={css({
|
||||
width: `${currentProblem.bottomWidths[1]}%`,
|
||||
height: '14px',
|
||||
bg: isDark ? 'green.400' : 'green.500',
|
||||
rounded: 'full',
|
||||
opacity: 0.9,
|
||||
alignSelf: 'flex-end',
|
||||
transition: 'width 0.4s ease-in-out',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal rule - animated wave */}
|
||||
<div
|
||||
className={css({
|
||||
width: '90%',
|
||||
height: '4px',
|
||||
bg: isDark ? 'gray.500' : 'gray.600',
|
||||
alignSelf: 'flex-end',
|
||||
my: '2',
|
||||
opacity: 0.85,
|
||||
rounded: 'full',
|
||||
animation: 'slideInRight 0.8s ease-out',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Answer area - glowing box */}
|
||||
<div
|
||||
style={{ animationDelay: `${parseFloat(animationDelay) + 0.5}s` }}
|
||||
className={css({
|
||||
width: '90%',
|
||||
height: '20px',
|
||||
bg: isDark ? 'gray.700' : 'white',
|
||||
border: '3px dashed',
|
||||
borderColor: isDark ? 'yellow.500' : 'yellow.500',
|
||||
rounded: 'lg',
|
||||
alignSelf: 'flex-end',
|
||||
opacity: 0.85,
|
||||
animation: 'glow 2.5s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PagePlaceholder({
|
||||
pageNumber,
|
||||
orientation = 'portrait',
|
||||
|
|
@ -21,21 +309,21 @@ export function PagePlaceholder({
|
|||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Calculate exact pixel dimensions based on page size
|
||||
// Portrait: 8.5" × 11" at 96 DPI = 816px × 1056px
|
||||
// Landscape: 11" × 8.5" at 96 DPI = 1056px × 816px
|
||||
// Scale down to fit typical viewport (maxWidth: 100%)
|
||||
const width = orientation === 'portrait' ? 816 : 1056
|
||||
const height = orientation === 'portrait' ? 1056 : 816
|
||||
// Calculate exact pixel dimensions and aspect ratios based on page size
|
||||
// Portrait: 8.5" × 11" at 96 DPI = 816px × 1056px (aspect ratio: 8.5/11)
|
||||
// Landscape: 11" × 8.5" at 96 DPI = 1056px × 816px (aspect ratio: 11/8.5)
|
||||
// Use max-width + aspect-ratio for responsive sizing that maintains proportions
|
||||
const maxWidth = orientation === 'portrait' ? 816 : 1056
|
||||
const aspectRatio = orientation === 'portrait' ? '8.5 / 11' : '11 / 8.5'
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="page-placeholder"
|
||||
data-page-number={pageNumber}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
width: '100%',
|
||||
maxWidth: `${maxWidth}px`,
|
||||
aspectRatio: aspectRatio,
|
||||
}}
|
||||
className={css({
|
||||
bg: isDark ? 'gray.800' : 'gray.100',
|
||||
|
|
@ -96,140 +384,25 @@ export function PagePlaceholder({
|
|||
flex: 1,
|
||||
})}
|
||||
>
|
||||
{Array.from({ length: cols }).map((_, colIndex) => (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={css({
|
||||
flex: 1,
|
||||
bg: isDark ? 'gray.700' : 'gray.300',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
||||
rounded: 'md',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '2',
|
||||
gap: '1',
|
||||
})}
|
||||
>
|
||||
{/* Problem number */}
|
||||
<div
|
||||
className={css({
|
||||
width: '20%',
|
||||
height: '2',
|
||||
bg: isDark ? 'gray.600' : 'gray.500',
|
||||
rounded: 'xs',
|
||||
})}
|
||||
{Array.from({ length: cols }).map((_, colIndex) => {
|
||||
// Generate pseudo-random but consistent properties for visual variety
|
||||
const seed = rowIndex * cols + colIndex
|
||||
const animationDelay = `${(seed % 10) * 0.1}s`
|
||||
const wiggleDelay = `${(seed % 8) * 0.15}s`
|
||||
|
||||
return (
|
||||
<AnimatedProblemCell
|
||||
key={colIndex}
|
||||
seed={seed}
|
||||
isDark={isDark}
|
||||
animationDelay={animationDelay}
|
||||
wiggleDelay={wiggleDelay}
|
||||
/>
|
||||
{/* Top operand */}
|
||||
<div
|
||||
className={css({
|
||||
width: '60%',
|
||||
height: '3',
|
||||
bg: isDark ? 'gray.600' : 'gray.500',
|
||||
rounded: 'xs',
|
||||
alignSelf: 'flex-end',
|
||||
})}
|
||||
/>
|
||||
{/* Bottom operand */}
|
||||
<div
|
||||
className={css({
|
||||
width: '60%',
|
||||
height: '3',
|
||||
bg: isDark ? 'gray.600' : 'gray.500',
|
||||
rounded: 'xs',
|
||||
alignSelf: 'flex-end',
|
||||
})}
|
||||
/>
|
||||
{/* Answer line */}
|
||||
<div
|
||||
className={css({
|
||||
width: '60%',
|
||||
height: '1px',
|
||||
bg: isDark ? 'gray.600' : 'gray.500',
|
||||
alignSelf: 'flex-end',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Page info overlay */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
zIndex: 1,
|
||||
bg: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(243, 244, 246, 0.95)',
|
||||
px: '6',
|
||||
py: '4',
|
||||
rounded: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.400',
|
||||
backdropFilter: 'blur(4px)',
|
||||
})}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
animation: 'spin',
|
||||
animationDuration: '1s',
|
||||
animationTimingFunction: 'linear',
|
||||
animationIterationCount: 'infinite',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
Loading page {pageNumber}...
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
📄
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
Page {pageNumber}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Scroll to load
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
|
||||
import { PagePlaceholder } from './PagePlaceholder'
|
||||
import type { WorksheetFormState } from '../types'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Layout/ResponsivePanelLayout',
|
||||
component: ResponsivePanelLayout,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ResponsivePanelLayout>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Mock sidebar content
|
||||
function MockSidebar() {
|
||||
return (
|
||||
<div style={{ padding: '20px', background: '#f9fafb', height: '100%' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '14px', fontWeight: 600 }}>
|
||||
Worksheet Settings
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div>
|
||||
<label
|
||||
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||
>
|
||||
Operator
|
||||
</label>
|
||||
<select style={{ width: '100%', padding: '6px', fontSize: '12px' }}>
|
||||
<option>Addition (+)</option>
|
||||
<option>Subtraction (−)</option>
|
||||
<option>Mixed (±)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||
>
|
||||
Digit Range
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={2}
|
||||
style={{ width: '60px', padding: '6px', fontSize: '12px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px' }}>to</span>
|
||||
<input
|
||||
type="number"
|
||||
value={3}
|
||||
style={{ width: '60px', padding: '6px', fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||
>
|
||||
Problems Per Page
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={20}
|
||||
style={{ width: '100%', padding: '6px', fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{ fontSize: '12px', fontWeight: 500, display: 'block', marginBottom: '4px' }}
|
||||
>
|
||||
Pages
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={5}
|
||||
style={{ width: '100%', padding: '6px', fontSize: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock preview content with virtual loading demonstration
|
||||
function MockPreviewWithVirtualLoading({ pages = 5 }: { pages?: number }) {
|
||||
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
background: '#e5e7eb',
|
||||
padding: '20px',
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const container = e.currentTarget
|
||||
const scrollTop = container.scrollTop
|
||||
const clientHeight = container.clientHeight
|
||||
|
||||
// Simulate loading pages as they come into view
|
||||
// Each page is about 1056px + 48px gap = 1104px
|
||||
const pageHeight = 1104
|
||||
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||
|
||||
const newLoadedPages = new Set(loadedPages)
|
||||
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, pages - 1); i++) {
|
||||
if (!newLoadedPages.has(i)) {
|
||||
newLoadedPages.add(i)
|
||||
// Simulate async loading delay
|
||||
setTimeout(() => {
|
||||
setLoadedPages((prev) => new Set([...prev, i]))
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}>
|
||||
{Array.from({ length: pages }).map((_, index) => (
|
||||
<div key={index} style={{ width: '100%', maxWidth: '816px' }}>
|
||||
<PagePlaceholder
|
||||
pageNumber={index + 1}
|
||||
orientation="portrait"
|
||||
rows={5}
|
||||
cols={4}
|
||||
loading={!loadedPages.has(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mockConfig: Partial<WorksheetFormState> = {
|
||||
operator: 'addition',
|
||||
digitRange: { min: 2, max: 3 },
|
||||
problemsPerPage: 20,
|
||||
pages: 5,
|
||||
cols: 4,
|
||||
}
|
||||
|
||||
export const DesktopLayout: Story = {
|
||||
render: () => (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<ResponsivePanelLayout
|
||||
config={mockConfig}
|
||||
sidebarContent={<MockSidebar />}
|
||||
previewContent={<MockPreviewWithVirtualLoading pages={5} />}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithVirtualLoading: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#eff6ff',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '600px',
|
||||
}}
|
||||
>
|
||||
<strong>🎯 Virtual Loading Demo:</strong> Scroll down in the preview panel to see pages
|
||||
load on-demand. Pages show a loading state (spinning hourglass) while being fetched, then
|
||||
display the placeholder once loaded.
|
||||
</div>
|
||||
<ResponsivePanelLayout
|
||||
config={mockConfig}
|
||||
sidebarContent={<MockSidebar />}
|
||||
previewContent={<MockPreviewWithVirtualLoading pages={20} />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const SinglePage: Story = {
|
||||
render: () => (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<ResponsivePanelLayout
|
||||
config={{ ...mockConfig, pages: 1 }}
|
||||
sidebarContent={<MockSidebar />}
|
||||
previewContent={<MockPreviewWithVirtualLoading pages={1} />}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ManyPages: Story = {
|
||||
render: () => (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#fef3c7',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<strong>⚡ Performance:</strong> 50 pages × 20 problems = 1,000 problems. Virtual loading
|
||||
keeps it smooth!
|
||||
</div>
|
||||
<ResponsivePanelLayout
|
||||
config={{ ...mockConfig, pages: 50 }}
|
||||
sidebarContent={<MockSidebar />}
|
||||
previewContent={<MockPreviewWithVirtualLoading pages={50} />}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const LandscapeOrientation: Story = {
|
||||
render: () => {
|
||||
function LandscapePreview() {
|
||||
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
background: '#e5e7eb',
|
||||
padding: '20px',
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const container = e.currentTarget
|
||||
const scrollTop = container.scrollTop
|
||||
const clientHeight = container.clientHeight
|
||||
const pageHeight = 864 // 816px + 48px gap
|
||||
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||
|
||||
const newLoadedPages = new Set(loadedPages)
|
||||
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, 4); i++) {
|
||||
if (!newLoadedPages.has(i)) {
|
||||
newLoadedPages.add(i)
|
||||
setTimeout(() => {
|
||||
setLoadedPages((prev) => new Set([...prev, i]))
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} style={{ width: '100%', maxWidth: '1056px' }}>
|
||||
<PagePlaceholder
|
||||
pageNumber={index + 1}
|
||||
orientation="landscape"
|
||||
rows={4}
|
||||
cols={5}
|
||||
loading={!loadedPages.has(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<ResponsivePanelLayout
|
||||
config={{ ...mockConfig, orientation: 'landscape' }}
|
||||
sidebarContent={<MockSidebar />}
|
||||
previewContent={<LandscapePreview />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ResizablePanels: Story = {
|
||||
render: () => (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#eff6ff',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<strong>↔️ Resizable:</strong> Drag the divider between panels to adjust the layout. Your
|
||||
preference is saved automatically!
|
||||
</div>
|
||||
<ResponsivePanelLayout
|
||||
config={mockConfig}
|
||||
sidebarContent={<MockSidebar />}
|
||||
previewContent={<MockPreviewWithVirtualLoading pages={5} />}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
|
@ -38,12 +38,19 @@ export function ShareModal({
|
|||
setError('')
|
||||
|
||||
try {
|
||||
const extractedConfig = extractConfigFields(config)
|
||||
console.log('[ShareModal] Creating share with config:', {
|
||||
pages: extractedConfig.pages,
|
||||
problemsPerPage: extractedConfig.problemsPerPage,
|
||||
totalProblems: (extractedConfig.pages || 0) * (extractedConfig.problemsPerPage || 0),
|
||||
})
|
||||
|
||||
const response = await fetch('/api/worksheets/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worksheetType,
|
||||
config: extractConfigFields(config),
|
||||
config: extractedConfig,
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { PagePlaceholder } from './PagePlaceholder'
|
||||
import type { WorksheetFormState } from '../types'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Preview/WorksheetPreview',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Mock preview component that simulates virtual loading without API calls
|
||||
function MockPreviewWithVirtualLoading({
|
||||
pages = 5,
|
||||
formState,
|
||||
}: {
|
||||
pages?: number
|
||||
formState: WorksheetFormState
|
||||
}) {
|
||||
const [loadedPages, setLoadedPages] = useState(new Set([0]))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
background: '#e5e7eb',
|
||||
padding: '20px',
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const container = e.currentTarget
|
||||
const scrollTop = container.scrollTop
|
||||
const clientHeight = container.clientHeight
|
||||
|
||||
// Simulate loading pages as they come into view
|
||||
// Each page is about 1056px + 48px gap = 1104px
|
||||
const pageHeight = 1104
|
||||
const visiblePageStart = Math.floor(scrollTop / pageHeight)
|
||||
const visiblePageEnd = Math.ceil((scrollTop + clientHeight) / pageHeight)
|
||||
|
||||
const newLoadedPages = new Set(loadedPages)
|
||||
for (let i = visiblePageStart; i <= Math.min(visiblePageEnd, pages - 1); i++) {
|
||||
if (!newLoadedPages.has(i)) {
|
||||
newLoadedPages.add(i)
|
||||
// Simulate async loading delay
|
||||
setTimeout(() => {
|
||||
setLoadedPages((prev) => new Set([...prev, i]))
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '48px', alignItems: 'center' }}>
|
||||
{Array.from({ length: pages }).map((_, index) => {
|
||||
const orientation = formState.orientation || 'portrait'
|
||||
const maxWidth = orientation === 'portrait' ? '816px' : '1056px'
|
||||
|
||||
return (
|
||||
<div key={index} style={{ width: '100%', maxWidth }}>
|
||||
<PagePlaceholder
|
||||
pageNumber={index + 1}
|
||||
orientation={orientation}
|
||||
rows={Math.ceil((formState.problemsPerPage || 20) / (formState.cols || 4))}
|
||||
cols={formState.cols || 4}
|
||||
loading={!loadedPages.has(index)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Wrapper component
|
||||
function PreviewWrapper({ formState, pages }: { formState: WorksheetFormState; pages?: number }) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<MockPreviewWithVirtualLoading formState={formState} pages={pages} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mockFormState: WorksheetFormState = {
|
||||
operator: 'addition',
|
||||
mode: 'manual',
|
||||
digitRange: { min: 2, max: 3 },
|
||||
problemsPerPage: 20,
|
||||
pages: 5,
|
||||
cols: 4,
|
||||
orientation: 'portrait',
|
||||
name: 'Student Name',
|
||||
displayRules: {
|
||||
tenFrames: 'sometimes',
|
||||
carryBoxes: 'sometimes',
|
||||
placeValueColors: 'sometimes',
|
||||
answerBoxes: 'always',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
pAnyStart: 0.3,
|
||||
pAllStart: 0.1,
|
||||
interpolate: false,
|
||||
seed: 12345,
|
||||
}
|
||||
|
||||
export const SinglePage: Story = {
|
||||
render: () => <PreviewWrapper formState={{ ...mockFormState, pages: 1 }} pages={1} />,
|
||||
}
|
||||
|
||||
export const FivePages: Story = {
|
||||
render: () => <PreviewWrapper formState={mockFormState} pages={5} />,
|
||||
}
|
||||
|
||||
export const ManyPages: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#fef3c7',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '700px',
|
||||
}}
|
||||
>
|
||||
<strong>⚡ Virtual Loading Demo:</strong> This worksheet has 20 pages (400 problems). Only
|
||||
visible pages are loaded. Scroll down to see pages load on-demand with a loading state
|
||||
(hourglass), then display content. Notice smooth scrolling even with hundreds of problems!
|
||||
</div>
|
||||
<PreviewWrapper formState={{ ...mockFormState, pages: 20 }} pages={20} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const ExtremeScale: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#fee2e2',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
maxWidth: '700px',
|
||||
}}
|
||||
>
|
||||
<strong>🚀 Extreme Scale:</strong> 50 pages × 20 problems = 1,000 problems! Virtual
|
||||
loading makes this performant. Only 3 pages load initially, rest load as you scroll. Try
|
||||
scrolling through - it stays smooth!
|
||||
</div>
|
||||
<PreviewWrapper formState={{ ...mockFormState, pages: 50 }} pages={50} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const LandscapeOrientation: Story = {
|
||||
render: () => (
|
||||
<PreviewWrapper
|
||||
formState={{ ...mockFormState, orientation: 'landscape', cols: 5, pages: 5 }}
|
||||
pages={5}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const DenseLayout: Story = {
|
||||
render: () => (
|
||||
<PreviewWrapper
|
||||
formState={{ ...mockFormState, problemsPerPage: 30, cols: 5, pages: 3 }}
|
||||
pages={3}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const SparseLayout: Story = {
|
||||
render: () => (
|
||||
<PreviewWrapper
|
||||
formState={{ ...mockFormState, problemsPerPage: 10, cols: 2, pages: 5 }}
|
||||
pages={5}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const InitialLoadingState: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ height: '100vh', width: '100vw' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#eff6ff',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<strong>🔄 Loading Behavior:</strong> This demonstrates the initial loading state. The
|
||||
first page loads immediately, others show placeholders with a loading spinner until you
|
||||
scroll them into view.
|
||||
</div>
|
||||
<PreviewWrapper formState={{ ...mockFormState, pages: 10 }} pages={10} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const VirtualizationExplained: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '20px',
|
||||
gap: '20px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#f3f4f6',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 16px 0', fontSize: '20px', fontWeight: 600 }}>
|
||||
Virtual Loading System
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
|
||||
<div>
|
||||
<strong>📦 Batch Loading:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Initial request loads first 3 pages. Additional pages load in batches as you scroll.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>👁️ Intersection Observer:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Tracks which pages are visible in the viewport. Triggers loading when pages come
|
||||
into view.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>🎯 Smart Preloading:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
When a page becomes visible, adjacent pages (above and below) are also loaded for
|
||||
smooth scrolling.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>⚡ Performance:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Handles 50+ page worksheets (1,000+ problems) smoothly. Only renders what's needed.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>💾 React Query Caching:</strong>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#6b7280' }}>
|
||||
Fetched pages are cached. Scrolling back doesn't re-fetch. Changes to settings
|
||||
invalidate cache.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '600px' }}>
|
||||
<PreviewWrapper formState={{ ...mockFormState, pages: 15 }} pages={15} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -422,6 +422,11 @@ function PreviewContent({
|
|||
const isVisible = visiblePages.has(index)
|
||||
const page = loadedPages.get(index)
|
||||
|
||||
// Calculate dimensions for consistent sizing between placeholder and loaded content
|
||||
const orientation = formState.orientation ?? 'portrait'
|
||||
const maxWidth = orientation === 'portrait' ? 816 : 1056
|
||||
const aspectRatio = orientation === 'portrait' ? '8.5 / 11' : '11 / 8.5'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
|
|
@ -440,11 +445,15 @@ function PreviewContent({
|
|||
>
|
||||
{isLoaded && page ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: `${maxWidth}px`,
|
||||
aspectRatio: aspectRatio,
|
||||
}}
|
||||
className={css({
|
||||
'& svg': {
|
||||
maxWidth: '100%',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
width: 'auto',
|
||||
},
|
||||
})}
|
||||
dangerouslySetInnerHTML={{ __html: page }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { OverallDifficultySlider } from './OverallDifficultySlider'
|
||||
import { DIFFICULTY_PROFILES } from '../../difficultyProfiles'
|
||||
import type { DisplayRules } from '../../displayRules'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Config Panel/OverallDifficultySlider',
|
||||
component: OverallDifficultySlider,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof OverallDifficultySlider>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Wrapper to handle state
|
||||
function SliderWrapper(args: React.ComponentProps<typeof OverallDifficultySlider>) {
|
||||
const [difficulty, setDifficulty] = useState(args.currentDifficulty)
|
||||
const [config, setConfig] = useState({ pAnyStart: 0, pAllStart: 0 })
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '500px' }}>
|
||||
<OverallDifficultySlider
|
||||
{...args}
|
||||
currentDifficulty={difficulty}
|
||||
onChange={(updates) => {
|
||||
const newDifficulty =
|
||||
(updates.pAnyStart + updates.pAllStart * 2) / 3 +
|
||||
(1 - Object.values(updates.displayRules).filter((v) => v === 'always').length / 10)
|
||||
setDifficulty(newDifficulty)
|
||||
setConfig({ pAnyStart: updates.pAnyStart, pAllStart: updates.pAllStart })
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Current Configuration:</strong>
|
||||
<div style={{ marginTop: '8px', fontSize: '11px', fontFamily: 'monospace' }}>
|
||||
pAnyStart: {config.pAnyStart.toFixed(2)}
|
||||
<br />
|
||||
pAllStart: {config.pAllStart.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EarlyLearner: Story = {
|
||||
render: (args) => <SliderWrapper {...args} />,
|
||||
args: {
|
||||
currentDifficulty: 2.0,
|
||||
},
|
||||
}
|
||||
|
||||
export const Intermediate: Story = {
|
||||
render: (args) => <SliderWrapper {...args} />,
|
||||
args: {
|
||||
currentDifficulty: 5.0,
|
||||
},
|
||||
}
|
||||
|
||||
export const Expert: Story = {
|
||||
render: (args) => <SliderWrapper {...args} />,
|
||||
args: {
|
||||
currentDifficulty: 8.5,
|
||||
},
|
||||
}
|
||||
|
||||
export const MinimumDifficulty: Story = {
|
||||
render: (args) => <SliderWrapper {...args} />,
|
||||
args: {
|
||||
currentDifficulty: 0,
|
||||
},
|
||||
}
|
||||
|
||||
export const MaximumDifficulty: Story = {
|
||||
render: (args) => <SliderWrapper {...args} />,
|
||||
args: {
|
||||
currentDifficulty: 10,
|
||||
},
|
||||
}
|
||||
|
||||
export const InteractiveWithPresets: Story = {
|
||||
render: () => {
|
||||
const [difficulty, setDifficulty] = useState(5.0)
|
||||
const [pAnyStart, setPAnyStart] = useState(0.5)
|
||||
const [pAllStart, setPAllStart] = useState(0.1)
|
||||
const [displayRules, setDisplayRules] = useState<DisplayRules>({
|
||||
tenFrames: 'sometimes',
|
||||
carryBoxes: 'sometimes',
|
||||
placeValueColors: 'sometimes',
|
||||
answerBoxes: 'always',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '14px', fontWeight: 600 }}>
|
||||
Difficulty Presets
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(DIFFICULTY_PROFILES).map(([key, profile]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const calcDiff =
|
||||
(profile.regrouping.pAnyStart + profile.regrouping.pAllStart * 2) / 3 +
|
||||
(1 -
|
||||
Object.values(profile.displayRules).filter((v) => v === 'always').length / 10)
|
||||
setDifficulty(calcDiff)
|
||||
setPAnyStart(profile.regrouping.pAnyStart)
|
||||
setPAllStart(profile.regrouping.pAllStart)
|
||||
setDisplayRules(profile.displayRules)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#fff',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
{profile.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OverallDifficultySlider
|
||||
currentDifficulty={difficulty}
|
||||
onChange={(updates) => {
|
||||
const newDiff =
|
||||
(updates.pAnyStart + updates.pAllStart * 2) / 3 +
|
||||
(1 - Object.values(updates.displayRules).filter((v) => v === 'always').length / 10)
|
||||
setDifficulty(newDiff)
|
||||
setPAnyStart(updates.pAnyStart)
|
||||
setPAllStart(updates.pAllStart)
|
||||
setDisplayRules(updates.displayRules)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Current State:</strong>
|
||||
<div style={{ marginTop: '8px', fontSize: '11px', fontFamily: 'monospace' }}>
|
||||
<div>Difficulty: {difficulty.toFixed(2)} / 10</div>
|
||||
<div>pAnyStart: {pAnyStart.toFixed(2)}</div>
|
||||
<div>pAllStart: {pAllStart.toFixed(2)}</div>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<strong>Display Rules:</strong>
|
||||
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||
{Object.entries(displayRules).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
{key}: {value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const SnappingBehavior: Story = {
|
||||
render: () => {
|
||||
const [difficulty, setDifficulty] = useState(5.0)
|
||||
const [snappedTo, setSnappedTo] = useState<string>('')
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '500px' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
padding: '12px',
|
||||
background: '#eff6ff',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>ℹ️ Snapping Behavior:</strong>
|
||||
<p style={{ margin: '8px 0 0 0' }}>
|
||||
As you drag the slider, it will snap to the nearest valid configuration along the
|
||||
pedagogical difficulty path. Watch how it snaps to combinations of regrouping intensity
|
||||
and scaffolding level.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OverallDifficultySlider
|
||||
currentDifficulty={difficulty}
|
||||
onChange={(updates) => {
|
||||
const newDiff =
|
||||
(updates.pAnyStart + updates.pAllStart * 2) / 3 +
|
||||
(1 - Object.values(updates.displayRules).filter((v) => v === 'always').length / 10)
|
||||
setDifficulty(newDiff)
|
||||
setSnappedTo(
|
||||
updates.difficultyProfile
|
||||
? DIFFICULTY_PROFILES[updates.difficultyProfile].label
|
||||
: 'Custom'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{snappedTo && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Snapped to:</strong> {snappedTo}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { RuleDropdown } from './RuleDropdown'
|
||||
import type { RuleMode } from '../../displayRules'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Config Panel/RuleDropdown',
|
||||
component: RuleDropdown,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof RuleDropdown>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Wrapper to handle state
|
||||
function DropdownWrapper(args: React.ComponentProps<typeof RuleDropdown>) {
|
||||
const [value, setValue] = useState<RuleMode>(args.value)
|
||||
return <RuleDropdown {...args} value={value} onChange={setValue} />
|
||||
}
|
||||
|
||||
export const AlwaysSelectedLight: Story = {
|
||||
render: (args) => <DropdownWrapper {...args} />,
|
||||
args: {
|
||||
label: 'Ten-Frame Diagrams',
|
||||
description: 'When should ten-frame visual aids be displayed?',
|
||||
value: 'always',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const NeverSelectedLight: Story = {
|
||||
render: (args) => <DropdownWrapper {...args} />,
|
||||
args: {
|
||||
label: 'Carry Notation Boxes',
|
||||
description: 'When should carry notation be shown?',
|
||||
value: 'never',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const WhenRegroupingLight: Story = {
|
||||
render: (args) => <DropdownWrapper {...args} />,
|
||||
args: {
|
||||
label: 'Place Value Colors',
|
||||
description: 'When should color-coding be applied?',
|
||||
value: 'whenRegrouping',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const AlwaysSelectedDark: Story = {
|
||||
render: (args) => <DropdownWrapper {...args} />,
|
||||
args: {
|
||||
label: 'Ten-Frame Diagrams',
|
||||
description: 'When should ten-frame visual aids be displayed?',
|
||||
value: 'always',
|
||||
isDark: true,
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: { default: 'dark' },
|
||||
},
|
||||
}
|
||||
|
||||
export const NeverSelectedDark: Story = {
|
||||
render: (args) => <DropdownWrapper {...args} />,
|
||||
args: {
|
||||
label: 'Carry Notation Boxes',
|
||||
description: 'When should carry notation be shown?',
|
||||
value: 'never',
|
||||
isDark: true,
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: { default: 'dark' },
|
||||
},
|
||||
}
|
||||
|
||||
export const MultipleDropdowns: Story = {
|
||||
render: () => {
|
||||
const [rules, setRules] = useState({
|
||||
tenFrames: 'always' as RuleMode,
|
||||
carryBoxes: 'whenRegrouping' as RuleMode,
|
||||
placeValueColors: 'never' as RuleMode,
|
||||
answerBoxes: 'always' as RuleMode,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '400px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Display Rules</h3>
|
||||
<RuleDropdown
|
||||
label="Ten-Frame Diagrams"
|
||||
description="Visual representations using ten-frames"
|
||||
value={rules.tenFrames}
|
||||
onChange={(value) => setRules({ ...rules, tenFrames: value })}
|
||||
/>
|
||||
<RuleDropdown
|
||||
label="Carry Notation Boxes"
|
||||
description="Small boxes above columns for carrying"
|
||||
value={rules.carryBoxes}
|
||||
onChange={(value) => setRules({ ...rules, carryBoxes: value })}
|
||||
/>
|
||||
<RuleDropdown
|
||||
label="Place Value Colors"
|
||||
description="Color-code digits by place value (ones, tens, hundreds)"
|
||||
value={rules.placeValueColors}
|
||||
onChange={(value) => setRules({ ...rules, placeValueColors: value })}
|
||||
/>
|
||||
<RuleDropdown
|
||||
label="Answer Entry Boxes"
|
||||
description="Boxes for students to write answers"
|
||||
value={rules.answerBoxes}
|
||||
onChange={(value) => setRules({ ...rules, answerBoxes: value })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '12px',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Current Configuration:</strong>
|
||||
<pre style={{ margin: '8px 0 0 0', fontSize: '10px', whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(rules, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const AllOptions: Story = {
|
||||
render: () => {
|
||||
const options: Array<{ value: RuleMode; label: string }> = [
|
||||
{ value: 'always', label: 'Always' },
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: 'whenRegrouping', label: 'When Regrouping' },
|
||||
{ value: 'whenMultipleRegroups', label: 'Multiple Regroups' },
|
||||
{ value: 'when3PlusDigits', label: '3+ Digits' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', maxWidth: '800px' }}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownWrapper
|
||||
key={option.value}
|
||||
label={`${option.label} Example`}
|
||||
description={`This dropdown is set to "${option.label}"`}
|
||||
value={option.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const LongLabel: Story = {
|
||||
render: (args) => <DropdownWrapper {...args} />,
|
||||
args: {
|
||||
label: 'Ten-Frame Diagrams for Visual Number Representation',
|
||||
description:
|
||||
'Determine when ten-frame visual aids should be displayed to help students understand number composition and place value. Ten-frames are particularly effective for students in early elementary grades.',
|
||||
value: 'always',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import { ToggleOption } from './ToggleOption'
|
||||
|
||||
const meta = {
|
||||
title: 'Worksheets/Config Panel/ToggleOption',
|
||||
component: ToggleOption,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ToggleOption>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Wrapper to handle state
|
||||
function ToggleWrapper(args: React.ComponentProps<typeof ToggleOption>) {
|
||||
const [checked, setChecked] = useState(args.checked)
|
||||
return <ToggleOption {...args} checked={checked} onChange={setChecked} />
|
||||
}
|
||||
|
||||
export const UncheckedLight: Story = {
|
||||
render: (args) => <ToggleWrapper {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
label: 'Progressive Difficulty',
|
||||
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const CheckedLight: Story = {
|
||||
render: (args) => <ToggleWrapper {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
label: 'Progressive Difficulty',
|
||||
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const UncheckedDark: Story = {
|
||||
render: (args) => <ToggleWrapper {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
label: 'Progressive Difficulty',
|
||||
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||
isDark: true,
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: { default: 'dark' },
|
||||
},
|
||||
}
|
||||
|
||||
export const CheckedDark: Story = {
|
||||
render: (args) => <ToggleWrapper {...args} />,
|
||||
args: {
|
||||
checked: true,
|
||||
label: 'Progressive Difficulty',
|
||||
description: 'Problems gradually increase in complexity throughout the worksheet',
|
||||
isDark: true,
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: { default: 'dark' },
|
||||
},
|
||||
}
|
||||
|
||||
export const WithChildren: Story = {
|
||||
render: (args) => (
|
||||
<ToggleWrapper {...args}>
|
||||
<div style={{ padding: '12px', borderTop: '1px solid #e5e7eb', background: '#f9fafb' }}>
|
||||
<p style={{ margin: 0, fontSize: '11px', color: '#6b7280' }}>
|
||||
Additional content can be displayed when this option is toggled on.
|
||||
</p>
|
||||
</div>
|
||||
</ToggleWrapper>
|
||||
),
|
||||
args: {
|
||||
checked: true,
|
||||
label: 'Show Answer Key',
|
||||
description: 'Include an answer key at the end of the worksheet',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const LongDescription: Story = {
|
||||
render: (args) => <ToggleWrapper {...args} />,
|
||||
args: {
|
||||
checked: false,
|
||||
label: 'Include Ten-Frame Diagrams',
|
||||
description:
|
||||
'Add visual ten-frame representations to help students visualize numbers and develop number sense. This is particularly helpful for early learners who are still developing their understanding of place value and number composition.',
|
||||
isDark: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const InteractiveDemo: Story = {
|
||||
render: () => {
|
||||
const [options, setOptions] = useState({
|
||||
progressive: false,
|
||||
answerKey: true,
|
||||
tenFrames: false,
|
||||
carryBoxes: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', maxWidth: '400px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>Worksheet Options</h3>
|
||||
<ToggleOption
|
||||
checked={options.progressive}
|
||||
onChange={(checked) => setOptions({ ...options, progressive: checked })}
|
||||
label="Progressive Difficulty"
|
||||
description="Problems gradually increase in complexity"
|
||||
/>
|
||||
<ToggleOption
|
||||
checked={options.answerKey}
|
||||
onChange={(checked) => setOptions({ ...options, answerKey: checked })}
|
||||
label="Include Answer Key"
|
||||
description="Add an answer key at the end"
|
||||
/>
|
||||
<ToggleOption
|
||||
checked={options.tenFrames}
|
||||
onChange={(checked) => setOptions({ ...options, tenFrames: checked })}
|
||||
label="Ten-Frame Diagrams"
|
||||
description="Show visual representations for numbers"
|
||||
/>
|
||||
<ToggleOption
|
||||
checked={options.carryBoxes}
|
||||
onChange={(checked) => setOptions({ ...options, carryBoxes: checked })}
|
||||
label="Carry Notation Boxes"
|
||||
description="Display boxes above columns for carrying"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Selected:</strong>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: '20px' }}>
|
||||
{Object.entries(options).map(
|
||||
([key, value]) => value && <li key={key}>{key.replace(/([A-Z])/g, ' $1')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -56,16 +56,54 @@ export type WorksheetConfig = AdditionConfigV4 & {
|
|||
*
|
||||
* This type is intentionally permissive during form editing to allow fields from
|
||||
* all modes to exist temporarily. Validation will enforce mode consistency.
|
||||
*
|
||||
* ## Field Categories (Config Persistence)
|
||||
*
|
||||
* **PRIMARY STATE** (persisted to localStorage/database):
|
||||
* - Most fields from AdditionConfigV4 (problemsPerPage, pages, cols, etc.)
|
||||
* - seed, prngAlgorithm (critical for reproducibility)
|
||||
*
|
||||
* **DERIVED STATE** (calculated, never persisted):
|
||||
* - `total` = problemsPerPage × pages
|
||||
* - `rows` = Math.ceil(problemsPerPage / cols)
|
||||
*
|
||||
* **EPHEMERAL STATE** (generated fresh, never persisted):
|
||||
* - `date` = current date when worksheet is generated
|
||||
*
|
||||
* See `.claude/WORKSHEET_CONFIG_PERSISTENCE.md` for full architecture.
|
||||
*/
|
||||
export type WorksheetFormState = Partial<Omit<AdditionConfigV4Smart, 'version'>> &
|
||||
Partial<Omit<AdditionConfigV4Manual, 'version'>> &
|
||||
Partial<Omit<AdditionConfigV4Mastery, 'version'>> & {
|
||||
// DERIVED state (calculated from primary state)
|
||||
// ========================================
|
||||
// DERIVED STATE (never persisted)
|
||||
// ========================================
|
||||
// These are calculated from primary state and excluded from persistence.
|
||||
// See extractConfigFields() blacklist for exclusion logic.
|
||||
|
||||
/** Derived: total = problemsPerPage × pages */
|
||||
rows?: number
|
||||
|
||||
/** Derived: rows = Math.ceil(problemsPerPage / cols) */
|
||||
total?: number
|
||||
|
||||
// ========================================
|
||||
// EPHEMERAL STATE (never persisted)
|
||||
// ========================================
|
||||
// Generated fresh at render time
|
||||
|
||||
/** Ephemeral: Current date when worksheet is generated */
|
||||
date?: string
|
||||
// Problem reproducibility (critical for sharing)
|
||||
|
||||
// ========================================
|
||||
// PRIMARY STATE (persisted)
|
||||
// ========================================
|
||||
// Critical for reproducibility when sharing worksheets
|
||||
|
||||
/** Primary: Random seed for reproducible problem generation */
|
||||
seed?: number
|
||||
|
||||
/** Primary: PRNG algorithm (ensures same random sequence across systems) */
|
||||
prngAlgorithm?: string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,58 +3,87 @@ import type { WorksheetFormState } from '../types'
|
|||
|
||||
/**
|
||||
* Extract only the persisted config fields from formState
|
||||
* Excludes derived state (rows, total, date)
|
||||
* INCLUDES seed and prngAlgorithm to ensure exact problem reproduction when shared
|
||||
*
|
||||
* This ensures consistent field extraction across:
|
||||
* - Auto-save (useWorksheetAutoSave)
|
||||
* - Share creation
|
||||
* - Settings API
|
||||
* ## Architecture: Blacklist Approach
|
||||
*
|
||||
* @param formState - The current form state (may be partial)
|
||||
* @returns Clean config object ready for serialization
|
||||
* This function uses a **blacklist approach** to extract config fields:
|
||||
* - Automatically includes ALL fields from formState
|
||||
* - Only excludes specific derived/ephemeral fields: rows, total, date
|
||||
*
|
||||
* ### Why Blacklist Instead of Whitelist?
|
||||
*
|
||||
* **Previous approach (FRAGILE):**
|
||||
* - Manually listed every field to include
|
||||
* - Adding new config fields required updating this function
|
||||
* - Forgetting to update caused shared worksheets to break
|
||||
* - Multiple incidents where new fields weren't shared correctly
|
||||
*
|
||||
* **Current approach (ROBUST):**
|
||||
* - New config fields automatically work in shared worksheets
|
||||
* - Only need to update if adding new DERIVED fields
|
||||
* - Much harder to accidentally break sharing
|
||||
*
|
||||
* ### Field Categories
|
||||
*
|
||||
* **PRIMARY STATE** (persisted):
|
||||
* - problemsPerPage, cols, pages - define worksheet structure
|
||||
* - digitRange, operator - define problem space
|
||||
* - pAnyStart, pAllStart, interpolate - control regrouping distribution
|
||||
* - mode, displayRules, difficultyProfile, etc. - control display behavior
|
||||
* - seed, prngAlgorithm - ensure exact problem reproduction when shared
|
||||
*
|
||||
* **DERIVED STATE** (excluded):
|
||||
* - `total` = problemsPerPage × pages (recalculated on load)
|
||||
* - `rows` = Math.ceil((problemsPerPage / cols)) (recalculated on load)
|
||||
*
|
||||
* **EPHEMERAL STATE** (excluded):
|
||||
* - `date` - generated fresh at render time, not persisted
|
||||
*
|
||||
* ### Usage
|
||||
*
|
||||
* This function ensures consistent extraction across:
|
||||
* - Auto-save to localStorage (useWorksheetAutoSave hook)
|
||||
* - Share link creation (POST /api/worksheets/share)
|
||||
* - Settings persistence (POST /api/worksheets/settings)
|
||||
*
|
||||
* ### Critical for Sharing
|
||||
*
|
||||
* The `seed` and `prngAlgorithm` fields are CRITICAL - they ensure that
|
||||
* shared worksheets generate the exact same problems when opened by others.
|
||||
* Without these, each person would see different random problems.
|
||||
*
|
||||
* @param formState - The current form state (may be partial during editing)
|
||||
* @returns Clean config object ready for serialization (JSON.stringify)
|
||||
*
|
||||
* @example
|
||||
* // In ShareModal component
|
||||
* const config = extractConfigFields(formState)
|
||||
* await fetch('/api/worksheets/share', {
|
||||
* method: 'POST',
|
||||
* body: JSON.stringify({ worksheetType: 'addition', config })
|
||||
* })
|
||||
*/
|
||||
export function extractConfigFields(
|
||||
formState: WorksheetFormState
|
||||
): Omit<AdditionConfigV4, 'version'> & { seed?: number; prngAlgorithm?: string } {
|
||||
const extracted = {
|
||||
problemsPerPage: formState.problemsPerPage!,
|
||||
cols: formState.cols!,
|
||||
pages: formState.pages!,
|
||||
orientation: formState.orientation!,
|
||||
name: formState.name!,
|
||||
digitRange: formState.digitRange!,
|
||||
operator: formState.operator!,
|
||||
pAnyStart: formState.pAnyStart!,
|
||||
pAllStart: formState.pAllStart!,
|
||||
interpolate: formState.interpolate!,
|
||||
showCarryBoxes: formState.showCarryBoxes,
|
||||
showAnswerBoxes: formState.showAnswerBoxes,
|
||||
showPlaceValueColors: formState.showPlaceValueColors,
|
||||
showProblemNumbers: formState.showProblemNumbers,
|
||||
showCellBorder: formState.showCellBorder,
|
||||
showTenFrames: formState.showTenFrames,
|
||||
showTenFramesForAll: formState.showTenFramesForAll,
|
||||
showBorrowNotation: formState.showBorrowNotation,
|
||||
showBorrowingHints: formState.showBorrowingHints,
|
||||
fontSize: formState.fontSize,
|
||||
mode: formState.mode!,
|
||||
difficultyProfile: formState.difficultyProfile,
|
||||
displayRules: formState.displayRules,
|
||||
manualPreset: formState.manualPreset,
|
||||
// Mastery mode fields (optional)
|
||||
currentStepId: formState.currentStepId,
|
||||
currentAdditionSkillId: formState.currentAdditionSkillId,
|
||||
currentSubtractionSkillId: formState.currentSubtractionSkillId,
|
||||
// CRITICAL: Include seed and algorithm to ensure exact same problems when sharing
|
||||
seed: formState.seed,
|
||||
prngAlgorithm: formState.prngAlgorithm ?? 'mulberry32',
|
||||
// Blacklist approach: Exclude only derived/ephemeral fields
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { rows, total, date, ...persistedFields } = formState
|
||||
|
||||
// Ensure prngAlgorithm has a default (critical for reproducibility)
|
||||
const config = {
|
||||
...persistedFields,
|
||||
prngAlgorithm: persistedFields.prngAlgorithm ?? 'mulberry32',
|
||||
}
|
||||
|
||||
console.log('[extractConfigFields] Extracted config:', {
|
||||
seed: extracted.seed,
|
||||
prngAlgorithm: extracted.prngAlgorithm,
|
||||
fieldCount: Object.keys(config).length,
|
||||
seed: config.seed,
|
||||
prngAlgorithm: config.prngAlgorithm,
|
||||
pages: config.pages,
|
||||
problemsPerPage: config.problemsPerPage,
|
||||
excludedFields: ['rows', 'total', 'date'],
|
||||
})
|
||||
|
||||
return extracted
|
||||
return config
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,20 +27,65 @@ function getDefaultDate(): string {
|
|||
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 > WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS) {
|
||||
errors.push(`Total problems must be between 1 and ${WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS}`)
|
||||
}
|
||||
|
||||
// Validate cols and auto-calculate rows
|
||||
// Validate cols first (needed for rows calculation)
|
||||
const cols = formState.cols ?? 4
|
||||
if (cols < 1 || cols > WORKSHEET_LIMITS.MAX_COLS) {
|
||||
errors.push(`Columns must be between 1 and ${WORKSHEET_LIMITS.MAX_COLS}`)
|
||||
}
|
||||
|
||||
// Auto-calculate rows to fit all problems
|
||||
const rows = Math.ceil(total / cols)
|
||||
// ========================================
|
||||
// PRIMARY STATE → DERIVED STATE
|
||||
// ========================================
|
||||
//
|
||||
// This section demonstrates the core principle of our config persistence:
|
||||
//
|
||||
// PRIMARY STATE (saved, source of truth):
|
||||
// - problemsPerPage: How many problems per page (e.g., 20)
|
||||
// - pages: How many pages (e.g., 5)
|
||||
//
|
||||
// DERIVED STATE (calculated, never saved):
|
||||
// - total = problemsPerPage × pages (e.g., 100)
|
||||
// - rows = Math.ceil(problemsPerPage / cols) (e.g., 5)
|
||||
//
|
||||
// Why this matters:
|
||||
// - When sharing worksheets, we only save PRIMARY state
|
||||
// - When loading shared worksheets, we MUST calculate DERIVED state
|
||||
// - Never use formState.total as fallback - it may be missing for shared worksheets!
|
||||
// - See .claude/WORKSHEET_CONFIG_PERSISTENCE.md for full architecture
|
||||
//
|
||||
// Example bug that was fixed (2025-01):
|
||||
// - Shared 100-page worksheet
|
||||
// - formState.total was missing (correctly excluded from share)
|
||||
// - Old code: total = formState.total ?? 20 (WRONG!)
|
||||
// - Result: Generated only 20 problems → 1 page instead of 100
|
||||
// - Fix: total = problemsPerPage × pages (from PRIMARY state)
|
||||
//
|
||||
|
||||
// Get primary state values (source of truth for calculation)
|
||||
const problemsPerPage = formState.problemsPerPage ?? (formState.total ?? 20)
|
||||
const pages = formState.pages ?? 1
|
||||
|
||||
// Calculate derived state: total = problemsPerPage × pages
|
||||
// DO NOT use formState.total as source of truth - it may be missing!
|
||||
const total = problemsPerPage * pages
|
||||
|
||||
console.log('[validateWorksheetConfig] PRIMARY → DERIVED state:', {
|
||||
// Primary (source of truth)
|
||||
problemsPerPage,
|
||||
pages,
|
||||
// Derived (calculated)
|
||||
total,
|
||||
// Debug: check if formState had these values
|
||||
hadTotal: formState.total !== undefined,
|
||||
totalMatches: formState.total === total,
|
||||
})
|
||||
|
||||
if (total < 1 || total > WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS) {
|
||||
errors.push(`Total problems must be between 1 and ${WORKSHEET_LIMITS.MAX_TOTAL_PROBLEMS}`)
|
||||
}
|
||||
|
||||
// Calculate derived state: rows based on problemsPerPage and cols
|
||||
const rows = Math.ceil(problemsPerPage / cols)
|
||||
|
||||
// Validate probabilities (0-1 range)
|
||||
// CRITICAL: Must check for undefined/null explicitly, not use ?? operator
|
||||
|
|
@ -111,10 +156,6 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
|||
// Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols)
|
||||
const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape')
|
||||
|
||||
// Get primary state values
|
||||
const problemsPerPage = formState.problemsPerPage ?? total
|
||||
const pages = formState.pages ?? 1
|
||||
|
||||
// Determine mode (default to 'smart' if not specified)
|
||||
const mode: 'smart' | 'manual' | 'mastery' = formState.mode ?? 'smart'
|
||||
|
||||
|
|
|
|||
|
|
@ -61,39 +61,53 @@ export default function SharedWorksheetPage() {
|
|||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('[SharedWorksheetPage] Loaded share data:', {
|
||||
pages: data.config.pages,
|
||||
problemsPerPage: data.config.problemsPerPage,
|
||||
totalProblems: (data.config.pages || 0) * (data.config.problemsPerPage || 0),
|
||||
fullConfig: data.config,
|
||||
})
|
||||
setShareData(data)
|
||||
|
||||
// Fetch preview from API
|
||||
try {
|
||||
const previewResponse = await fetch('/api/worksheets/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: data.config }),
|
||||
})
|
||||
// Don't pre-fetch preview for large worksheets - let WorksheetPreview handle virtual loading
|
||||
// Only pre-fetch for small worksheets (≤5 pages) to improve initial load experience
|
||||
if (data.config.pages && data.config.pages <= 5) {
|
||||
console.log('[SharedWorksheetPage] Pre-fetching preview for small worksheet...')
|
||||
try {
|
||||
const previewResponse = await fetch('/api/worksheets/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: data.config }),
|
||||
})
|
||||
|
||||
if (previewResponse.ok) {
|
||||
const previewData = await previewResponse.json()
|
||||
if (previewData.success) {
|
||||
setPreview(previewData.pages)
|
||||
if (previewResponse.ok) {
|
||||
const previewData = await previewResponse.json()
|
||||
if (previewData.success) {
|
||||
setPreview(previewData.pages)
|
||||
} else {
|
||||
// Preview generation failed - store error details
|
||||
setPreviewError({
|
||||
error: previewData.error || 'Failed to generate preview',
|
||||
details: previewData.details,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Preview generation failed - store error details
|
||||
setPreviewError({
|
||||
error: previewData.error || 'Failed to generate preview',
|
||||
details: previewData.details,
|
||||
error: 'Preview generation failed',
|
||||
details: `HTTP ${previewResponse.status}: ${previewResponse.statusText}`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
} catch (err) {
|
||||
console.error('Failed to generate preview:', err)
|
||||
setPreviewError({
|
||||
error: 'Preview generation failed',
|
||||
details: `HTTP ${previewResponse.status}: ${previewResponse.statusText}`,
|
||||
error: 'Failed to generate preview',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to generate preview:', err)
|
||||
setPreviewError({
|
||||
error: 'Failed to generate preview',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
} else {
|
||||
console.log(
|
||||
`[SharedWorksheetPage] Skipping pre-fetch for large worksheet (${data.config.pages} pages) - using virtual loading`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching shared worksheet:', err)
|
||||
|
|
|
|||
Loading…
Reference in New Issue