From cd1b3edc15495818655d7c2aaaf2777064fc8a2f Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Fri, 7 Nov 2025 16:58:54 -0600 Subject: [PATCH] feat(worksheets): add V3 config schema with Smart/Manual mode discrimination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../worksheets/addition/manualModePresets.ts | 102 +++++++++ .../app/create/worksheets/addition/types.ts | 61 +++--- .../app/create/worksheets/config-schemas.ts | 194 ++++++++++++++++-- 3 files changed, 305 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/app/create/worksheets/addition/manualModePresets.ts diff --git a/apps/web/src/app/create/worksheets/addition/manualModePresets.ts b/apps/web/src/app/create/worksheets/addition/manualModePresets.ts new file mode 100644 index 00000000..cd894091 --- /dev/null +++ b/apps/web/src/app/create/worksheets/addition/manualModePresets.ts @@ -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 + +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' +} diff --git a/apps/web/src/app/create/worksheets/addition/types.ts b/apps/web/src/app/create/worksheets/addition/types.ts index f5ac09ac..ee2955b5 100644 --- a/apps/web/src/app/create/worksheets/addition/types.ts +++ b/apps/web/src/app/create/worksheets/addition/types.ts @@ -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> & { - // 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> & + Partial> & { + // DERIVED state (calculated from primary state) + rows?: number + total?: number + date?: string + seed?: number + } /** * A single addition problem diff --git a/apps/web/src/app/create/worksheets/config-schemas.ts b/apps/web/src/app/create/worksheets/config-schemas.ts index 4386c66d..50ebd7f4 100644 --- a/apps/web/src/app/create/worksheets/config-schemas.ts +++ b/apps/web/src/app/create/worksheets/config-schemas.ts @@ -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 +/** + * 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 +export type AdditionConfigV3Smart = z.infer +export type AdditionConfigV3Manual = z.infer + /** Union of all addition config versions (add new versions here) */ export const additionConfigSchema = z.discriminatedUnion('version', [ additionConfigV1Schema, additionConfigV2Schema, + additionConfigV3Schema, ]) export type AdditionConfig = z.infer /** - * 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): string { - const versioned: AdditionConfigV2 = { +export function serializeAdditionConfig(config: Omit): string { + const versioned: AdditionConfigV3 = { ...config, version: ADDITION_CURRENT_VERSION, - } + } as AdditionConfigV3 return JSON.stringify(versioned) }