feat(worksheets): add V3 config schema with Smart/Manual mode discrimination

- Add V3 schema with discriminated union on 'mode' field
- Smart mode: uses displayRules for conditional per-problem scaffolding
- Manual mode: uses boolean flags for uniform display across all problems
- Add automatic migration from V2 to V3
- Add manual mode presets (fullScaffolding, minimalScaffolding, etc.)
- Update WorksheetConfig and WorksheetFormState types to support both modes

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-07 16:58:54 -06:00
parent ca7102059f
commit cd1b3edc15
3 changed files with 305 additions and 52 deletions

View File

@ -0,0 +1,102 @@
// Manual mode presets for direct display control
export interface ManualModePreset {
name: string
label: string
description: string
showCarryBoxes: boolean
showAnswerBoxes: boolean
showPlaceValueColors: boolean
showTenFrames: boolean
showProblemNumbers: boolean
showCellBorder: boolean
showTenFramesForAll: boolean
}
/**
* Pre-defined manual mode presets for common use cases
* Unlike smart mode presets, these are simple on/off toggles
*/
export const MANUAL_MODE_PRESETS = {
fullScaffolding: {
name: 'fullScaffolding',
label: 'Full Scaffolding',
description: 'All visual aids enabled for maximum support',
showCarryBoxes: true,
showAnswerBoxes: true,
showPlaceValueColors: true,
showTenFrames: false, // Off by default, can enable separately
showProblemNumbers: true,
showCellBorder: true,
showTenFramesForAll: false,
},
minimalScaffolding: {
name: 'minimalScaffolding',
label: 'Minimal Scaffolding',
description: 'Basic structure only - for students building independence',
showCarryBoxes: false,
showAnswerBoxes: false,
showPlaceValueColors: false,
showTenFrames: false,
showProblemNumbers: true,
showCellBorder: true,
showTenFramesForAll: false,
},
assessmentMode: {
name: 'assessmentMode',
label: 'Assessment Mode',
description: 'Clean layout for testing - minimal visual aids',
showCarryBoxes: false,
showAnswerBoxes: false,
showPlaceValueColors: false,
showTenFrames: false,
showProblemNumbers: true,
showCellBorder: false,
showTenFramesForAll: false,
},
tenFramesFocus: {
name: 'tenFramesFocus',
label: 'Ten-Frames Focus',
description: 'All aids plus ten-frames for concrete visualization',
showCarryBoxes: true,
showAnswerBoxes: true,
showPlaceValueColors: true,
showTenFrames: true,
showProblemNumbers: true,
showCellBorder: true,
showTenFramesForAll: false,
},
} as const satisfies Record<string, ManualModePreset>
export type ManualModePresetName = keyof typeof MANUAL_MODE_PRESETS
/**
* Check if manual display settings match a preset
*/
export function getManualPresetFromConfig(config: {
showCarryBoxes: boolean
showAnswerBoxes: boolean
showPlaceValueColors: boolean
showTenFrames: boolean
showProblemNumbers: boolean
showCellBorder: boolean
showTenFramesForAll: boolean
}): ManualModePresetName | 'custom' {
for (const [name, preset] of Object.entries(MANUAL_MODE_PRESETS)) {
if (
preset.showCarryBoxes === config.showCarryBoxes &&
preset.showAnswerBoxes === config.showAnswerBoxes &&
preset.showPlaceValueColors === config.showPlaceValueColors &&
preset.showTenFrames === config.showTenFrames &&
preset.showProblemNumbers === config.showProblemNumbers &&
preset.showCellBorder === config.showCellBorder &&
preset.showTenFramesForAll === config.showTenFramesForAll
) {
return name as ManualModePresetName
}
}
return 'custom'
}

View File

@ -1,14 +1,20 @@
// Type definitions for double-digit addition worksheet creator
import type { AdditionConfigV2 } from '../config-schemas'
import type {
AdditionConfigV3,
AdditionConfigV3Smart,
AdditionConfigV3Manual,
} from '../config-schemas'
/**
* Complete, validated configuration for worksheet generation
* Extends V2 config with additional derived fields needed for rendering
* Extends V3 config with additional derived fields needed for rendering
*
* Note: Includes V1 compatibility fields during migration period
* V3 uses discriminated union on 'mode':
* - Smart mode: Uses displayRules for conditional per-problem scaffolding
* - Manual mode: Uses boolean flags for uniform display across all problems
*/
export type WorksheetConfig = AdditionConfigV2 & {
export type WorksheetConfig = AdditionConfigV3 & {
// Problem set - DERIVED state
total: number // total = problemsPerPage * pages
rows: number // rows = (problemsPerPage / cols) * pages
@ -28,41 +34,30 @@ export type WorksheetConfig = AdditionConfigV2 & {
top: number
bottom: number
}
// V1 compatibility: Include individual boolean flags during migration
// These will be derived from displayRules during validation
showCarryBoxes: boolean
showAnswerBoxes: boolean
showPlaceValueColors: boolean
showProblemNumbers: boolean
showCellBorder: boolean
showTenFrames: boolean
}
/**
* Partial form state - user may be editing, fields optional
* Based on V2 config with additional derived state
* Based on V3 config with additional derived state
*
* Note: For backwards compatibility during migration, this type accepts either:
* - V2 displayRules (preferred)
* - V1 individual boolean flags (will be converted to displayRules)
* V3 supports two modes via discriminated union:
* - Smart mode: Has displayRules and optional difficultyProfile
* - Manual mode: Has boolean display flags and optional manualPreset
*
* During editing, mode field may be present to indicate which mode is active.
* If mode is absent, defaults to 'smart' mode.
*
* This type is intentionally permissive during form editing to allow fields from
* both modes to exist temporarily. Validation will enforce mode consistency.
*/
export type WorksheetFormState = Partial<Omit<AdditionConfigV2, 'version'>> & {
// DERIVED state (calculated from primary state)
rows?: number
total?: number
date?: string
seed?: number
// V1 compatibility: Accept individual boolean flags
// These will be converted to displayRules internally
showCarryBoxes?: boolean
showAnswerBoxes?: boolean
showPlaceValueColors?: boolean
showProblemNumbers?: boolean
showCellBorder?: boolean
showTenFrames?: boolean
}
export type WorksheetFormState = Partial<Omit<AdditionConfigV3Smart, 'version'>> &
Partial<Omit<AdditionConfigV3Manual, 'version'>> & {
// DERIVED state (calculated from primary state)
rows?: number
total?: number
date?: string
seed?: number
}
/**
* A single addition problem

View File

@ -21,7 +21,7 @@ import { getProfileFromConfig } from './addition/difficultyProfiles'
// =============================================================================
/** Current schema version for addition worksheets */
const ADDITION_CURRENT_VERSION = 2
const ADDITION_CURRENT_VERSION = 3
/**
* Addition worksheet config - Version 1
@ -120,19 +120,128 @@ export const additionConfigV2Schema = z.object({
export type AdditionConfigV2 = z.infer<typeof additionConfigV2Schema>
/**
* Addition worksheet config - Version 3
* Two-mode system: Smart Difficulty vs Manual Control
*/
// Shared base fields for both modes
const additionConfigV3BaseSchema = z.object({
version: z.literal(3),
// Core worksheet settings
problemsPerPage: z.number().int().min(1).max(100),
cols: z.number().int().min(1).max(10),
pages: z.number().int().min(1).max(20),
orientation: z.enum(['portrait', 'landscape']),
name: z.string(),
fontSize: z.number().int().min(8).max(32),
// Regrouping probabilities (shared between modes)
pAnyStart: z.number().min(0).max(1),
pAllStart: z.number().min(0).max(1),
interpolate: z.boolean(),
})
// Smart Difficulty Mode
const additionConfigV3SmartSchema = additionConfigV3BaseSchema.extend({
mode: z.literal('smart'),
// Conditional display rules
displayRules: z.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
}),
// Optional: Which smart difficulty profile is selected
difficultyProfile: z.string().optional(),
// showTenFramesForAll is deprecated in V3 smart mode
// (controlled by displayRules.tenFrames)
})
// Manual Control Mode
const additionConfigV3ManualSchema = additionConfigV3BaseSchema.extend({
mode: z.literal('manual'),
// Simple boolean toggles
showCarryBoxes: z.boolean(),
showAnswerBoxes: z.boolean(),
showPlaceValueColors: z.boolean(),
showTenFrames: z.boolean(),
showProblemNumbers: z.boolean(),
showCellBorder: z.boolean(),
showTenFramesForAll: z.boolean(),
// Optional: Which manual preset is selected
manualPreset: z.string().optional(),
})
// V3 uses discriminated union on 'mode'
export const additionConfigV3Schema = z.discriminatedUnion('mode', [
additionConfigV3SmartSchema,
additionConfigV3ManualSchema,
])
export type AdditionConfigV3 = z.infer<typeof additionConfigV3Schema>
export type AdditionConfigV3Smart = z.infer<typeof additionConfigV3SmartSchema>
export type AdditionConfigV3Manual = z.infer<typeof additionConfigV3ManualSchema>
/** Union of all addition config versions (add new versions here) */
export const additionConfigSchema = z.discriminatedUnion('version', [
additionConfigV1Schema,
additionConfigV2Schema,
additionConfigV3Schema,
])
export type AdditionConfig = z.infer<typeof additionConfigSchema>
/**
* Default addition config (always latest version)
* Default addition config (always latest version - V3 Smart Mode)
*/
export const defaultAdditionConfig: AdditionConfigV2 = {
version: 2,
export const defaultAdditionConfig: AdditionConfigV3Smart = {
version: 3,
mode: 'smart',
problemsPerPage: 20,
cols: 5,
pages: 1,
@ -150,7 +259,6 @@ export const defaultAdditionConfig: AdditionConfigV2 = {
cellBorders: 'always',
},
difficultyProfile: 'earlyLearner',
showTenFramesForAll: false,
fontSize: 16,
}
@ -191,10 +299,58 @@ function migrateAdditionV1toV2(v1: AdditionConfigV1): AdditionConfigV2 {
}
/**
* Migrate addition config from any version to latest
* Migrate V2 config to V3
* Determines mode based on whether difficultyProfile is set
*/
function migrateAdditionV2toV3(v2: AdditionConfigV2): AdditionConfigV3 {
// If user has a difficultyProfile set, they're using smart mode
if (v2.difficultyProfile) {
return {
version: 3,
mode: 'smart',
problemsPerPage: v2.problemsPerPage,
cols: v2.cols,
pages: v2.pages,
orientation: v2.orientation,
name: v2.name,
fontSize: v2.fontSize,
pAnyStart: v2.pAnyStart,
pAllStart: v2.pAllStart,
interpolate: v2.interpolate,
displayRules: v2.displayRules,
difficultyProfile: v2.difficultyProfile,
}
}
// No preset → Manual mode
// Convert displayRules to boolean flags
return {
version: 3,
mode: 'manual',
problemsPerPage: v2.problemsPerPage,
cols: v2.cols,
pages: v2.pages,
orientation: v2.orientation,
name: v2.name,
fontSize: v2.fontSize,
pAnyStart: v2.pAnyStart,
pAllStart: v2.pAllStart,
interpolate: v2.interpolate,
showCarryBoxes: v2.displayRules.carryBoxes === 'always',
showAnswerBoxes: v2.displayRules.answerBoxes === 'always',
showPlaceValueColors: v2.displayRules.placeValueColors === 'always',
showTenFrames: v2.displayRules.tenFrames === 'always',
showProblemNumbers: v2.displayRules.problemNumbers === 'always',
showCellBorder: v2.displayRules.cellBorders === 'always',
showTenFramesForAll: v2.showTenFramesForAll,
}
}
/**
* Migrate addition config from any version to latest (V3)
* @throws {Error} if config is invalid or migration fails
*/
export function migrateAdditionConfig(rawConfig: unknown): AdditionConfigV2 {
export function migrateAdditionConfig(rawConfig: unknown): AdditionConfigV3 {
// First, try to parse as any known version
const parsed = additionConfigSchema.safeParse(rawConfig)
@ -209,17 +365,17 @@ export function migrateAdditionConfig(rawConfig: unknown): AdditionConfigV2 {
// Migrate to latest version
switch (config.version) {
case 1:
// Migrate V1 to V2
return migrateAdditionV1toV2(config)
// Migrate V1 → V2 → V3
return migrateAdditionV2toV3(migrateAdditionV1toV2(config))
case 2:
// Migrate V2 → V3
return migrateAdditionV2toV3(config)
case 3:
// Already latest version
return config
// Future migrations:
// case 3:
// return migrateAdditionV2toV3(config)
default:
// Unknown version, return defaults
console.warn(`Unknown addition config version: ${(config as any).version}`)
@ -229,9 +385,9 @@ export function migrateAdditionConfig(rawConfig: unknown): AdditionConfigV2 {
/**
* Parse and validate addition config from JSON string
* Automatically migrates old versions to latest
* Automatically migrates old versions to latest (V3)
*/
export function parseAdditionConfig(jsonString: string): AdditionConfigV2 {
export function parseAdditionConfig(jsonString: string): AdditionConfigV3 {
try {
const raw = JSON.parse(jsonString)
return migrateAdditionConfig(raw)
@ -243,13 +399,13 @@ export function parseAdditionConfig(jsonString: string): AdditionConfigV2 {
/**
* Serialize addition config to JSON string
* Ensures version field is set to current version
* Ensures version field is set to current version (V3)
*/
export function serializeAdditionConfig(config: Omit<AdditionConfigV2, 'version'>): string {
const versioned: AdditionConfigV2 = {
export function serializeAdditionConfig(config: Omit<AdditionConfigV3, 'version'>): string {
const versioned: AdditionConfigV3 = {
...config,
version: ADDITION_CURRENT_VERSION,
}
} as AdditionConfigV3
return JSON.stringify(versioned)
}