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:
parent
ca7102059f
commit
cd1b3edc15
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue