From 7fbc743c4c213866a36e758eaf9b49200ea79f13 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Tue, 11 Nov 2025 15:04:19 -0600 Subject: [PATCH] feat: add skill configuration system with interactive 2D difficulty plot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive skill customization system allowing teachers to: - Configure existing default skills with custom difficulty settings - Create entirely new custom skills from scratch - Visualize skills in mastery progression context with directional edges - Interact with difficulty space using 2D plot with hover tooltips Database Schema: - custom_skills table: Stores user-created skills - skill_customizations table: Stores modifications to default skills - Both tables track regrouping config, display rules, and metadata API Endpoints: - POST /api/worksheets/skills/custom - Create custom skill - GET /api/worksheets/skills/custom - List custom skills - PUT /api/worksheets/skills/custom/[id] - Update custom skill - DELETE /api/worksheets/skills/custom/[id] - Delete custom skill - POST /api/worksheets/skills/[skillId]/customize - Save customization - GET /api/worksheets/skills/customizations - List customizations Components: - DifficultyPlot2D: Interactive 2D visualization of difficulty space - Regrouping Intensity (x-axis) × Scaffolding Level (y-axis) - Dual mode: Default presets vs Mastery progression skills - Directional edges showing skill progression sequence - Hover tooltips with skill details - Click to select configuration - Visual legend explaining elements - SkillConfigurationModal: Modal for skill configuration - Name and description fields - Digit range slider - 2D difficulty plot integration - Shows mastery progression context when editing - Real-time configuration summary - MasteryModePanel Integration: - "Configure Skill" button for existing skills - "Create Custom Skill" button for new skills - Passes mastery progression to modal for context Visual Design: - Purple theme (#9333ea) for mastery progression skills - Green theme (#10b981) for current configuration - Dashed arrows with triangular arrow heads - Numbered skill circles with hover tooltips - Compact legend in top-right corner Technical Features: - PlotPoint interface for custom skill plotting - Conditional snapping to either presets or custom points - Vector math for arrow head calculations - Z-ordering: edges before points - Event propagation control for hover interactions - Storybook examples for both components Bug Fixes: - Fix page indicator stuck on page 1 in WorksheetPreview - Changed from threshold-based to most-visible-page tracking - Works correctly for both scroll directions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/.claude/settings.local.json | 7 +- apps/web/drizzle/meta/0022_snapshot.json | 112 +-- apps/web/drizzle/meta/_journal.json | 2 +- .../skills/[skillId]/customize/route.ts | 154 +++++ .../worksheets/skills/custom/[id]/route.ts | 101 +++ .../app/api/worksheets/skills/custom/route.ts | 97 +++ .../worksheets/skills/customizations/route.ts | 40 ++ .../components/WorksheetPreview.tsx | 22 +- .../config-panel/DifficultyPlot2D.stories.tsx | 145 ++++ .../config-panel/DifficultyPlot2D.tsx | 640 ++++++++++++++++++ .../config-panel/MasteryModePanel.tsx | 168 ++++- .../SkillConfigurationModal.stories.tsx | 138 ++++ .../config-panel/SkillConfigurationModal.tsx | 382 +++++++++++ 13 files changed, 1913 insertions(+), 95 deletions(-) create mode 100644 apps/web/src/app/api/worksheets/skills/[skillId]/customize/route.ts create mode 100644 apps/web/src/app/api/worksheets/skills/custom/[id]/route.ts create mode 100644 apps/web/src/app/api/worksheets/skills/custom/route.ts create mode 100644 apps/web/src/app/api/worksheets/skills/customizations/route.ts create mode 100644 apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.stories.tsx create mode 100644 apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.tsx create mode 100644 apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.stories.tsx create mode 100644 apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.tsx diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index d6d1f7fb..871c2013 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -52,13 +52,12 @@ "Bash(pnpm install)", "Bash(npx @biomejs/biome check:*)", "Bash(node -e:*)", - "Bash(sqlite3:*)" + "Bash(sqlite3:*)", + "Bash(npm run format:check:*)" ], "deny": [], "ask": [] }, "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "sqlite" - ] + "enabledMcpjsonServers": ["sqlite"] } diff --git a/apps/web/drizzle/meta/0022_snapshot.json b/apps/web/drizzle/meta/0022_snapshot.json index 97278865..94b84beb 100644 --- a/apps/web/drizzle/meta/0022_snapshot.json +++ b/apps/web/drizzle/meta/0022_snapshot.json @@ -116,13 +116,9 @@ "abacus_settings_user_id_users_id_fk": { "name": "abacus_settings_user_id_users_id_fk", "tableFrom": "abacus_settings", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -240,9 +236,7 @@ "indexes": { "arcade_rooms_code_unique": { "name": "arcade_rooms_code_unique", - "columns": [ - "code" - ], + "columns": ["code"], "isUnique": true } }, @@ -339,26 +333,18 @@ "arcade_sessions_room_id_arcade_rooms_id_fk": { "name": "arcade_sessions_room_id_arcade_rooms_id_fk", "tableFrom": "arcade_sessions", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" }, "arcade_sessions_user_id_users_id_fk": { "name": "arcade_sessions_user_id_users_id_fk", "tableFrom": "arcade_sessions", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -424,9 +410,7 @@ "indexes": { "players_user_id_idx": { "name": "players_user_id_idx", - "columns": [ - "user_id" - ], + "columns": ["user_id"], "isUnique": false } }, @@ -434,13 +418,9 @@ "players_user_id_users_id_fk": { "name": "players_user_id_users_id_fk", "tableFrom": "players", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -514,9 +494,7 @@ "indexes": { "idx_room_members_user_id_unique": { "name": "idx_room_members_user_id_unique", - "columns": [ - "user_id" - ], + "columns": ["user_id"], "isUnique": true } }, @@ -524,13 +502,9 @@ "room_members_room_id_arcade_rooms_id_fk": { "name": "room_members_room_id_arcade_rooms_id_fk", "tableFrom": "room_members", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -605,13 +579,9 @@ "room_member_history_room_id_arcade_rooms_id_fk": { "name": "room_member_history_room_id_arcade_rooms_id_fk", "tableFrom": "room_member_history", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -713,10 +683,7 @@ "indexes": { "idx_room_invitations_user_room": { "name": "idx_room_invitations_user_room", - "columns": [ - "user_id", - "room_id" - ], + "columns": ["user_id", "room_id"], "isUnique": true } }, @@ -724,13 +691,9 @@ "room_invitations_room_id_arcade_rooms_id_fk": { "name": "room_invitations_room_id_arcade_rooms_id_fk", "tableFrom": "room_invitations", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -833,13 +796,9 @@ "room_reports_room_id_arcade_rooms_id_fk": { "name": "room_reports_room_id_arcade_rooms_id_fk", "tableFrom": "room_reports", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -918,10 +877,7 @@ "indexes": { "idx_room_bans_user_room": { "name": "idx_room_bans_user_room", - "columns": [ - "user_id", - "room_id" - ], + "columns": ["user_id", "room_id"], "isUnique": true } }, @@ -929,13 +885,9 @@ "room_bans_room_id_arcade_rooms_id_fk": { "name": "room_bans_room_id_arcade_rooms_id_fk", "tableFrom": "room_bans", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -998,13 +950,9 @@ "user_stats_user_id_users_id_fk": { "name": "user_stats_user_id_users_id_fk", "tableFrom": "user_stats", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -1062,16 +1010,12 @@ "indexes": { "users_guest_id_unique": { "name": "users_guest_id_unique", - "columns": [ - "guest_id" - ], + "columns": ["guest_id"], "isUnique": true }, "users_email_unique": { "name": "users_email_unique", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -1091,4 +1035,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index c8452e0a..06d2b456 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -164,4 +164,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/worksheets/skills/[skillId]/customize/route.ts b/apps/web/src/app/api/worksheets/skills/[skillId]/customize/route.ts new file mode 100644 index 00000000..892b83ba --- /dev/null +++ b/apps/web/src/app/api/worksheets/skills/[skillId]/customize/route.ts @@ -0,0 +1,154 @@ +import { eq, and } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db } from '@/db' +import { skillCustomizations } from '@/db/schema' +import { getViewerId } from '@/lib/viewer' + +/** + * POST /api/worksheets/skills/[skillId]/customize + * + * Save a customization of a default skill + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ skillId: string }> } +) { + try { + const viewerId = await getViewerId() + const { skillId } = await params + const body = await request.json() + + const { operator, digitRange, regroupingConfig, displayRules } = body + + // Validate required fields + if (!operator || !digitRange || !regroupingConfig || !displayRules) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) + } + + // Validate operator + if (operator !== 'addition' && operator !== 'subtraction') { + return NextResponse.json({ error: 'Invalid operator' }, { status: 400 }) + } + + const now = new Date().toISOString() + + // Check if customization already exists + const existing = await db.query.skillCustomizations.findFirst({ + where: and( + eq(skillCustomizations.userId, viewerId), + eq(skillCustomizations.skillId, skillId), + eq(skillCustomizations.operator, operator) + ), + }) + + if (existing) { + // Update existing customization + await db + .update(skillCustomizations) + .set({ + digitRange: JSON.stringify(digitRange), + regroupingConfig: JSON.stringify(regroupingConfig), + displayRules: JSON.stringify(displayRules), + updatedAt: now, + }) + .where( + and( + eq(skillCustomizations.userId, viewerId), + eq(skillCustomizations.skillId, skillId), + eq(skillCustomizations.operator, operator) + ) + ) + } else { + // Insert new customization + await db.insert(skillCustomizations).values({ + userId: viewerId, + skillId, + operator, + digitRange: JSON.stringify(digitRange), + regroupingConfig: JSON.stringify(regroupingConfig), + displayRules: JSON.stringify(displayRules), + updatedAt: now, + }) + } + + // Fetch the updated/created customization + const customization = await db.query.skillCustomizations.findFirst({ + where: and( + eq(skillCustomizations.userId, viewerId), + eq(skillCustomizations.skillId, skillId), + eq(skillCustomizations.operator, operator) + ), + }) + + if (!customization) { + return NextResponse.json({ error: 'Failed to fetch customization' }, { status: 500 }) + } + + // Return parsed customization + return NextResponse.json({ + customization: { + ...customization, + digitRange: JSON.parse(customization.digitRange), + regroupingConfig: JSON.parse(customization.regroupingConfig), + displayRules: JSON.parse(customization.displayRules), + }, + }) + } catch (error) { + console.error('Failed to save skill customization:', error) + return NextResponse.json({ error: 'Failed to save skill customization' }, { status: 500 }) + } +} + +/** + * DELETE /api/worksheets/skills/[skillId]/customize?operator=addition + * + * Reset a skill to its default by deleting the customization + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ skillId: string }> } +) { + try { + const viewerId = await getViewerId() + const { skillId } = await params + const { searchParams } = new URL(request.url) + const operator = searchParams.get('operator') as 'addition' | 'subtraction' | null + + if (!operator) { + return NextResponse.json({ error: 'Operator is required' }, { status: 400 }) + } + + if (operator !== 'addition' && operator !== 'subtraction') { + return NextResponse.json({ error: 'Invalid operator' }, { status: 400 }) + } + + // Check if customization exists + const existing = await db.query.skillCustomizations.findFirst({ + where: and( + eq(skillCustomizations.userId, viewerId), + eq(skillCustomizations.skillId, skillId), + eq(skillCustomizations.operator, operator) + ), + }) + + if (!existing) { + return NextResponse.json({ error: 'Skill customization not found' }, { status: 404 }) + } + + // Delete the customization + await db + .delete(skillCustomizations) + .where( + and( + eq(skillCustomizations.userId, viewerId), + eq(skillCustomizations.skillId, skillId), + eq(skillCustomizations.operator, operator) + ) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Failed to delete skill customization:', error) + return NextResponse.json({ error: 'Failed to delete skill customization' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/worksheets/skills/custom/[id]/route.ts b/apps/web/src/app/api/worksheets/skills/custom/[id]/route.ts new file mode 100644 index 00000000..0e3103d5 --- /dev/null +++ b/apps/web/src/app/api/worksheets/skills/custom/[id]/route.ts @@ -0,0 +1,101 @@ +import { eq, and } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db } from '@/db' +import { customSkills } from '@/db/schema' +import { getViewerId } from '@/lib/viewer' + +/** + * PUT /api/worksheets/skills/custom/[id] + * + * Update an existing custom skill + */ +export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const viewerId = await getViewerId() + const { id } = await params + const body = await request.json() + + const { name, description, digitRange, regroupingConfig, displayRules } = body + + // Verify skill exists and belongs to user + const existing = await db.query.customSkills.findFirst({ + where: and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)), + }) + + if (!existing) { + return NextResponse.json({ error: 'Custom skill not found' }, { status: 404 }) + } + + // Build update object (only update provided fields) + const updates: Record = { + updatedAt: new Date().toISOString(), + } + + if (name) updates.name = name + if (description !== undefined) updates.description = description + if (digitRange) updates.digitRange = JSON.stringify(digitRange) + if (regroupingConfig) updates.regroupingConfig = JSON.stringify(regroupingConfig) + if (displayRules) updates.displayRules = JSON.stringify(displayRules) + + await db + .update(customSkills) + .set(updates) + .where(and(eq(customSkills.id, id), eq(customSkills.userId, viewerId))) + + // Fetch updated skill + const updated = await db.query.customSkills.findFirst({ + where: and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)), + }) + + if (!updated) { + return NextResponse.json({ error: 'Failed to fetch updated skill' }, { status: 500 }) + } + + // Return parsed skill + return NextResponse.json({ + skill: { + ...updated, + digitRange: JSON.parse(updated.digitRange), + regroupingConfig: JSON.parse(updated.regroupingConfig), + displayRules: JSON.parse(updated.displayRules), + }, + }) + } catch (error) { + console.error('Failed to update custom skill:', error) + return NextResponse.json({ error: 'Failed to update custom skill' }, { status: 500 }) + } +} + +/** + * DELETE /api/worksheets/skills/custom/[id] + * + * Delete a custom skill + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const viewerId = await getViewerId() + const { id } = await params + + // Verify skill exists and belongs to user + const existing = await db.query.customSkills.findFirst({ + where: and(eq(customSkills.id, id), eq(customSkills.userId, viewerId)), + }) + + if (!existing) { + return NextResponse.json({ error: 'Custom skill not found' }, { status: 404 }) + } + + // Delete the skill + await db + .delete(customSkills) + .where(and(eq(customSkills.id, id), eq(customSkills.userId, viewerId))) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Failed to delete custom skill:', error) + return NextResponse.json({ error: 'Failed to delete custom skill' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/worksheets/skills/custom/route.ts b/apps/web/src/app/api/worksheets/skills/custom/route.ts new file mode 100644 index 00000000..0fd49260 --- /dev/null +++ b/apps/web/src/app/api/worksheets/skills/custom/route.ts @@ -0,0 +1,97 @@ +import { eq, and } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db } from '@/db' +import { customSkills } from '@/db/schema' +import { getViewerId } from '@/lib/viewer' +import { nanoid } from 'nanoid' + +/** + * GET /api/worksheets/skills/custom + * + * Get all custom skills for the current user + */ +export async function GET(request: NextRequest) { + try { + const viewerId = await getViewerId() + const { searchParams } = new URL(request.url) + const operator = searchParams.get('operator') as 'addition' | 'subtraction' | null + + const query = operator + ? and(eq(customSkills.userId, viewerId), eq(customSkills.operator, operator)) + : eq(customSkills.userId, viewerId) + + const skills = await db.query.customSkills.findMany({ + where: query, + orderBy: (customSkills, { asc }) => [asc(customSkills.createdAt)], + }) + + // Parse JSON fields + const parsed = skills.map((skill) => ({ + ...skill, + digitRange: JSON.parse(skill.digitRange), + regroupingConfig: JSON.parse(skill.regroupingConfig), + displayRules: JSON.parse(skill.displayRules), + })) + + return NextResponse.json({ skills: parsed }) + } catch (error) { + console.error('Failed to fetch custom skills:', error) + return NextResponse.json({ error: 'Failed to fetch custom skills' }, { status: 500 }) + } +} + +/** + * POST /api/worksheets/skills/custom + * + * Create a new custom skill + */ +export async function POST(request: NextRequest) { + try { + const viewerId = await getViewerId() + const body = await request.json() + + const { name, description, operator, digitRange, regroupingConfig, displayRules } = body + + // Validate required fields + if (!name || !operator || !digitRange || !regroupingConfig || !displayRules) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) + } + + // Validate operator + if (operator !== 'addition' && operator !== 'subtraction') { + return NextResponse.json({ error: 'Invalid operator' }, { status: 400 }) + } + + // Generate ID with custom prefix + const id = `custom-${nanoid(10)}` + const now = new Date().toISOString() + + const newSkill = { + id, + userId: viewerId, + operator, + name, + description: description || null, + digitRange: JSON.stringify(digitRange), + regroupingConfig: JSON.stringify(regroupingConfig), + displayRules: JSON.stringify(displayRules), + createdAt: now, + updatedAt: now, + } + + await db.insert(customSkills).values(newSkill) + + // Return parsed skill + return NextResponse.json({ + skill: { + ...newSkill, + digitRange, + regroupingConfig, + displayRules, + }, + }) + } catch (error) { + console.error('Failed to create custom skill:', error) + return NextResponse.json({ error: 'Failed to create custom skill' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/api/worksheets/skills/customizations/route.ts b/apps/web/src/app/api/worksheets/skills/customizations/route.ts new file mode 100644 index 00000000..c6f38fa5 --- /dev/null +++ b/apps/web/src/app/api/worksheets/skills/customizations/route.ts @@ -0,0 +1,40 @@ +import { eq, and } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db } from '@/db' +import { skillCustomizations } from '@/db/schema' +import { getViewerId } from '@/lib/viewer' + +/** + * GET /api/worksheets/skills/customizations?operator=addition + * + * Get all skill customizations for the current user + */ +export async function GET(request: NextRequest) { + try { + const viewerId = await getViewerId() + const { searchParams } = new URL(request.url) + const operator = searchParams.get('operator') as 'addition' | 'subtraction' | null + + const query = operator + ? and(eq(skillCustomizations.userId, viewerId), eq(skillCustomizations.operator, operator)) + : eq(skillCustomizations.userId, viewerId) + + const customizations = await db.query.skillCustomizations.findMany({ + where: query, + orderBy: (skillCustomizations, { asc }) => [asc(skillCustomizations.updatedAt)], + }) + + // Parse JSON fields + const parsed = customizations.map((customization) => ({ + ...customization, + digitRange: JSON.parse(customization.digitRange), + regroupingConfig: JSON.parse(customization.regroupingConfig), + displayRules: JSON.parse(customization.displayRules), + })) + + return NextResponse.json({ customizations: parsed }) + } catch (error) { + console.error('Failed to fetch skill customizations:', error) + return NextResponse.json({ error: 'Failed to fetch skill customizations' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/create/worksheets/components/WorksheetPreview.tsx b/apps/web/src/app/create/worksheets/components/WorksheetPreview.tsx index 94221dbd..51e64db7 100644 --- a/apps/web/src/app/create/worksheets/components/WorksheetPreview.tsx +++ b/apps/web/src/app/create/worksheets/components/WorksheetPreview.tsx @@ -171,6 +171,23 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe const observer = new IntersectionObserver( (entries) => { + // Find the most visible page among all entries + let mostVisiblePage = currentPage + let maxRatio = 0 + + entries.forEach((entry) => { + if (entry.intersectionRatio > maxRatio) { + maxRatio = entry.intersectionRatio + mostVisiblePage = Number(entry.target.getAttribute('data-page-index')) + } + }) + + // Update current page if we found a more visible page + if (maxRatio > 0) { + setCurrentPage(mostVisiblePage) + } + + // Update visible pages set setVisiblePages((prev) => { const next = new Set(prev) @@ -183,11 +200,6 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe // Preload adjacent pages for smooth scrolling if (pageIndex > 0) next.add(pageIndex - 1) if (pageIndex < totalPages - 1) next.add(pageIndex + 1) - - // Update current page indicator based on most visible page - if (entry.intersectionRatio > 0.5) { - setCurrentPage(pageIndex) - } } }) diff --git a/apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.stories.tsx b/apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.stories.tsx new file mode 100644 index 00000000..c450515c --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { DifficultyPlot2D } from './DifficultyPlot2D' +import { DIFFICULTY_PROFILES } from '../../difficultyProfiles' + +const meta = { + title: 'Worksheets/Config Panel/DifficultyPlot2D', + component: DifficultyPlot2D, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const EarlyLearner: Story = { + args: { + pAnyStart: DIFFICULTY_PROFILES.earlyLearner.regrouping.pAnyStart, + pAllStart: DIFFICULTY_PROFILES.earlyLearner.regrouping.pAllStart, + displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules, + onChange: (config) => { + console.log('Selected configuration:', config) + }, + isDark: false, + }, +} + +export const Intermediate: Story = { + args: { + pAnyStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAnyStart, + pAllStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAllStart, + displayRules: DIFFICULTY_PROFILES.intermediate.displayRules, + onChange: (config) => { + console.log('Selected configuration:', config) + }, + isDark: false, + }, +} + +export const Expert: Story = { + args: { + pAnyStart: DIFFICULTY_PROFILES.expert.regrouping.pAnyStart, + pAllStart: DIFFICULTY_PROFILES.expert.regrouping.pAllStart, + displayRules: DIFFICULTY_PROFILES.expert.displayRules, + onChange: (config) => { + console.log('Selected configuration:', config) + }, + isDark: false, + }, +} + +export const CustomConfiguration: Story = { + args: { + pAnyStart: 0.5, + pAllStart: 0.1, + displayRules: { + carryBoxes: 'sometimes', + answerBoxes: 'always', + placeValueColors: 'never', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + }, + onChange: (config) => { + console.log('Selected configuration:', config) + }, + isDark: false, + }, +} + +export const DarkMode: Story = { + args: { + pAnyStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAnyStart, + pAllStart: DIFFICULTY_PROFILES.intermediate.regrouping.pAllStart, + displayRules: DIFFICULTY_PROFILES.intermediate.displayRules, + onChange: (config) => { + console.log('Selected configuration:', config) + }, + isDark: true, + }, + parameters: { + backgrounds: { default: 'dark' }, + }, +} + +export const MasteryProgressionView: Story = { + args: { + pAnyStart: 0.5, + pAllStart: 0.1, + displayRules: { + carryBoxes: 'sometimes', + answerBoxes: 'always', + placeValueColors: 'never', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + }, + onChange: (config) => { + console.log('Selected configuration:', config) + }, + isDark: false, + customPoints: [ + { + id: 'skill-1', + label: 'Single Digit', + pAnyStart: 0, + pAllStart: 0, + displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules, + }, + { + id: 'skill-2', + label: 'No Regroup', + pAnyStart: 0, + pAllStart: 0, + displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules, + }, + { + id: 'skill-3', + label: 'Some Regroup', + pAnyStart: 0.25, + pAllStart: 0, + displayRules: DIFFICULTY_PROFILES.intermediate.displayRules, + }, + { + id: 'skill-4', + label: 'More Regroup', + pAnyStart: 0.5, + pAllStart: 0.1, + displayRules: DIFFICULTY_PROFILES.intermediate.displayRules, + }, + { + id: 'skill-5', + label: 'Advanced', + pAnyStart: 0.75, + pAllStart: 0.25, + displayRules: DIFFICULTY_PROFILES.expert.displayRules, + }, + ], + }, +} diff --git a/apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.tsx b/apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.tsx new file mode 100644 index 00000000..3ae517d5 --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/config-panel/DifficultyPlot2D.tsx @@ -0,0 +1,640 @@ +'use client' + +import { useState } from 'react' +import { css } from '@styled/css' +import { + DIFFICULTY_PROFILES, + DIFFICULTY_PROGRESSION, + calculateRegroupingIntensity, + calculateScaffoldingLevel, + REGROUPING_PROGRESSION, + SCAFFOLDING_PROGRESSION, + findNearestValidState, + getProfileFromConfig, +} from '../../difficultyProfiles' +import type { DisplayRules } from '../../displayRules' + +export interface PlotPoint { + id: string + label: string + pAnyStart: number + pAllStart: number + displayRules: DisplayRules +} + +export interface DifficultyPlot2DProps { + /** Current regrouping config */ + pAnyStart: number + pAllStart: number + /** Current display rules */ + displayRules: DisplayRules + /** Callback when user clicks a point on the plot */ + onChange: (config: { + pAnyStart: number + pAllStart: number + displayRules: DisplayRules + matchedProfile: string + }) => void + /** Whether to show the plot in dark mode */ + isDark?: boolean + /** Custom points to plot (instead of default difficulty presets) */ + customPoints?: PlotPoint[] +} + +/** + * Interactive 2D plot for difficulty configuration + * Shows regrouping intensity (x-axis) vs scaffolding level (y-axis) + * Allows users to click to select a difficulty configuration + */ +export function DifficultyPlot2D({ + pAnyStart, + pAllStart, + displayRules, + onChange, + isDark = false, + customPoints, +}: DifficultyPlot2DProps) { + const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number } | null>(null) + const [hoveredSkill, setHoveredSkill] = useState(null) + + // Use custom points if provided, otherwise use default presets + const pointsToPlot = customPoints ?? null + + // Make responsive - use container width with max size + const maxSize = 500 + const width = maxSize + const height = maxSize + const padding = 40 + const graphWidth = width - padding * 2 + const graphHeight = height - padding * 2 + + const currentReg = calculateRegroupingIntensity(pAnyStart, pAllStart) + const currentScaf = calculateScaffoldingLevel(displayRules, currentReg) + + // Convert 0-10 scale to SVG coordinates + const toX = (val: number) => padding + (val / 10) * graphWidth + const toY = (val: number) => height - padding - (val / 10) * graphHeight + + // Convert SVG coordinates to 0-10 scale + const fromX = (x: number) => Math.max(0, Math.min(10, ((x - padding) / graphWidth) * 10)) + const fromY = (y: number) => + Math.max(0, Math.min(10, ((height - padding - y) / graphHeight) * 10)) + + // Helper to calculate valid target from mouse position + const calculateValidTarget = (clientX: number, clientY: number, svg: SVGSVGElement) => { + const rect = svg.getBoundingClientRect() + const x = clientX - rect.left + const y = clientY - rect.top + + // Convert to difficulty space (0-10) + const regroupingIntensity = fromX(x) + const scaffoldingLevel = fromY(y) + + // Check if we're near a preset (within snap threshold) + const snapThreshold = 1.0 // 1.0 units in 0-10 scale + let nearestPreset: { + distance: number + pAnyStart: number + pAllStart: number + displayRules: DisplayRules + id: string + } | null = null + + // If custom points provided, snap to those instead of default presets + if (pointsToPlot) { + for (const point of pointsToPlot) { + const presetReg = calculateRegroupingIntensity(point.pAnyStart, point.pAllStart) + const presetScaf = calculateScaffoldingLevel(point.displayRules, presetReg) + + // Calculate Euclidean distance + const distance = Math.sqrt( + (regroupingIntensity - presetReg) ** 2 + (scaffoldingLevel - presetScaf) ** 2 + ) + + if (distance <= snapThreshold) { + if (!nearestPreset || distance < nearestPreset.distance) { + nearestPreset = { + distance, + pAnyStart: point.pAnyStart, + pAllStart: point.pAllStart, + displayRules: point.displayRules, + id: point.id, + } + } + } + } + } else { + // Use default difficulty profiles + for (const profileName of DIFFICULTY_PROGRESSION) { + const p = DIFFICULTY_PROFILES[profileName] + const presetReg = calculateRegroupingIntensity( + p.regrouping.pAnyStart, + p.regrouping.pAllStart + ) + const presetScaf = calculateScaffoldingLevel(p.displayRules, presetReg) + + // Calculate Euclidean distance + const distance = Math.sqrt( + (regroupingIntensity - presetReg) ** 2 + (scaffoldingLevel - presetScaf) ** 2 + ) + + if (distance <= snapThreshold) { + if (!nearestPreset || distance < nearestPreset.distance) { + nearestPreset = { + distance, + pAnyStart: p.regrouping.pAnyStart, + pAllStart: p.regrouping.pAllStart, + displayRules: p.displayRules, + id: p.name, + } + } + } + } + } + + // If we found a nearby preset, snap to it + if (nearestPreset) { + return { + newRegrouping: { pAnyStart: nearestPreset.pAnyStart, pAllStart: nearestPreset.pAllStart }, + newDisplayRules: nearestPreset.displayRules, + matchedProfile: nearestPreset.id, + reg: calculateRegroupingIntensity(nearestPreset.pAnyStart, nearestPreset.pAllStart), + scaf: calculateScaffoldingLevel( + nearestPreset.displayRules, + calculateRegroupingIntensity(nearestPreset.pAnyStart, nearestPreset.pAllStart) + ), + } + } + + // No preset nearby, use normal progression indices + const regroupingIdx = Math.round( + (regroupingIntensity / 10) * (REGROUPING_PROGRESSION.length - 1) + ) + const scaffoldingIdx = Math.round( + ((10 - scaffoldingLevel) / 10) * (SCAFFOLDING_PROGRESSION.length - 1) + ) + + // Find nearest valid state (applies pedagogical constraints) + const validState = findNearestValidState(regroupingIdx, scaffoldingIdx) + + // Get actual values from progressions + const newRegrouping = REGROUPING_PROGRESSION[validState.regroupingIdx] + const newDisplayRules = SCAFFOLDING_PROGRESSION[validState.scaffoldingIdx] + + // Calculate display coordinates + const reg = calculateRegroupingIntensity(newRegrouping.pAnyStart, newRegrouping.pAllStart) + const scaf = calculateScaffoldingLevel(newDisplayRules, reg) + + // Check if this matches a preset + const matchedProfile = getProfileFromConfig( + newRegrouping.pAllStart, + newRegrouping.pAnyStart, + newDisplayRules + ) + + return { + newRegrouping, + newDisplayRules, + matchedProfile, + reg, + scaf, + } + } + + const handleMouseMove = (e: React.MouseEvent) => { + // Don't show hover preview if we're already hovering over a skill + if (hoveredSkill) { + setHoverPoint(null) + return + } + + const svg = e.currentTarget + const target = calculateValidTarget(e.clientX, e.clientY, svg) + setHoverPoint({ x: target.reg, y: target.scaf }) + } + + const handleMouseLeave = () => { + setHoverPoint(null) + setHoveredSkill(null) + } + + const handleClick = (e: React.MouseEvent) => { + const svg = e.currentTarget + const target = calculateValidTarget(e.clientX, e.clientY, svg) + + // Call onChange with new configuration + onChange({ + pAnyStart: target.newRegrouping.pAnyStart, + pAllStart: target.newRegrouping.pAllStart, + displayRules: target.newDisplayRules, + matchedProfile: target.matchedProfile, + }) + } + + return ( +
+ + {/* Grid lines */} + {[0, 2, 4, 6, 8, 10].map((val) => ( + + + + + ))} + + {/* Axes */} + + + + {/* Axis labels */} + + Regrouping Intensity → + + + Scaffolding (more help) → + + + {/* Preset points or custom skill points */} + {pointsToPlot ? ( + <> + {/* Custom points (other skills in mastery progression) */} + {/* First, render directional edges connecting skills in order */} + {pointsToPlot.map((point, index) => { + if (index === pointsToPlot.length - 1) return null // Skip last point (no edge after it) + + const nextPoint = pointsToPlot[index + 1] + + const fromReg = calculateRegroupingIntensity(point.pAnyStart, point.pAllStart) + const fromScaf = calculateScaffoldingLevel(point.displayRules, fromReg) + const toReg = calculateRegroupingIntensity(nextPoint.pAnyStart, nextPoint.pAllStart) + const toScaf = calculateScaffoldingLevel(nextPoint.displayRules, toReg) + + const fromX = toX(fromReg) + const fromY = toY(fromScaf) + const toXPos = toX(toReg) + const toYPos = toY(toScaf) + + // Calculate arrow head + const dx = toXPos - fromX + const dy = toYPos - fromY + const length = Math.sqrt(dx * dx + dy * dy) + const unitX = dx / length + const unitY = dy / length + + // Shorten line to not overlap with circles (radius 5) + const startX = fromX + unitX * 8 + const startY = fromY + unitY * 8 + const endX = toXPos - unitX * 8 + const endY = toYPos - unitY * 8 + + // Arrow head + const arrowSize = 8 + const arrowAngle = Math.PI / 6 // 30 degrees + const angle = Math.atan2(dy, dx) + + const arrowPoint1X = endX - arrowSize * Math.cos(angle - arrowAngle) + const arrowPoint1Y = endY - arrowSize * Math.sin(angle - arrowAngle) + const arrowPoint2X = endX - arrowSize * Math.cos(angle + arrowAngle) + const arrowPoint2Y = endY - arrowSize * Math.sin(angle + arrowAngle) + + return ( + + {/* Edge line */} + + {/* Arrow head */} + + + ) + })} + + {/* Then render the skill points on top of edges */} + {pointsToPlot.map((point, index) => { + const reg = calculateRegroupingIntensity(point.pAnyStart, point.pAllStart) + const scaf = calculateScaffoldingLevel(point.displayRules, reg) + + return ( + { + e.stopPropagation() + setHoveredSkill(point) + setHoverPoint(null) + }} + onMouseLeave={(e) => { + e.stopPropagation() + setHoveredSkill(null) + }} + onMouseMove={(e) => { + e.stopPropagation() + }} + style={{ cursor: 'help' }} + > + {/* Larger invisible circle for easier hovering */} + + {/* Visible skill circle */} + + {/* Ordinal number inside circle */} + + {index + 1} + + + ) + })} + + ) : ( + // Default difficulty presets + DIFFICULTY_PROGRESSION.map((profileName) => { + const p = DIFFICULTY_PROFILES[profileName] + const reg = calculateRegroupingIntensity(p.regrouping.pAnyStart, p.regrouping.pAllStart) + const scaf = calculateScaffoldingLevel(p.displayRules, reg) + + return ( + + + + {p.label} + + + ) + }) + )} + + {/* Hover preview - show where click will land */} + {hoverPoint && ( + <> + {/* Dashed line from hover to target */} + + {/* Hover target marker */} + + + + )} + + {/* Current position */} + + + + + {/* Hover tooltip for skill information */} + {hoveredSkill && pointsToPlot && ( +
+
+ Skill {pointsToPlot.findIndex((p) => p.id === hoveredSkill.id) + 1} +
+
+ {hoveredSkill.label} +
+
+ )} + + {/* Legend for custom points */} + {pointsToPlot && ( +
+
+
+ # +
+ Skills in progression +
+
+
+ Current configuration +
+
+ Hover skills for details +
+
+ )} +
+ ) +} diff --git a/apps/web/src/app/create/worksheets/components/config-panel/MasteryModePanel.tsx b/apps/web/src/app/create/worksheets/components/config-panel/MasteryModePanel.tsx index 8e0719bc..a4f4d5cc 100644 --- a/apps/web/src/app/create/worksheets/components/config-panel/MasteryModePanel.tsx +++ b/apps/web/src/app/create/worksheets/components/config-panel/MasteryModePanel.tsx @@ -8,6 +8,8 @@ import type { SkillId } from '../../skills' import { getSkillById, getSkillsByOperator } from '../../skills' import { AllSkillsModal } from './AllSkillsModal' import { CustomizeMixModal } from './CustomizeMixModal' +import { SkillConfigurationModal } from './SkillConfigurationModal' +import type { DisplayRules } from '../../displayRules' interface MasteryModePanelProps { formState: WorksheetFormState @@ -27,6 +29,8 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master const [isLoadingMastery, setIsLoadingMastery] = useState(true) const [isAllSkillsModalOpen, setIsAllSkillsModalOpen] = useState(false) const [isCustomizeMixModalOpen, setIsCustomizeMixModalOpen] = useState(false) + const [isConfigureModalOpen, setIsConfigureModalOpen] = useState(false) + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) // Get current operator (default to addition) const operator = formState.operator ?? 'addition' @@ -216,6 +220,85 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master } } + // Handler: Save skill customization + const handleSaveCustomization = async (config: { + name: string + description?: string + digitRange: { min: number; max: number } + regroupingConfig: { pAnyStart: number; pAllStart: number } + displayRules: DisplayRules + }) => { + if (!currentSkill) return + + try { + const response = await fetch(`/api/worksheets/skills/${currentSkill.id}/customize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operator, + digitRange: config.digitRange, + regroupingConfig: config.regroupingConfig, + displayRules: config.displayRules, + }), + }) + + if (!response.ok) { + throw new Error('Failed to save skill customization') + } + + // Apply the new configuration to the form state + onChange({ + digitRange: config.digitRange, + pAnyStart: config.regroupingConfig.pAnyStart, + pAllStart: config.regroupingConfig.pAllStart, + displayRules: config.displayRules, + } as Partial) + + console.log('Skill customization saved successfully') + } catch (error) { + console.error('Failed to save skill customization:', error) + alert('Failed to save skill customization. Please try again.') + } + } + + // Handler: Create custom skill + const handleCreateCustomSkill = async (config: { + name: string + description?: string + digitRange: { min: number; max: number } + regroupingConfig: { pAnyStart: number; pAllStart: number } + displayRules: DisplayRules + }) => { + try { + const response = await fetch('/api/worksheets/skills/custom', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: config.name, + description: config.description, + operator, + digitRange: config.digitRange, + regroupingConfig: config.regroupingConfig, + displayRules: config.displayRules, + }), + }) + + if (!response.ok) { + throw new Error('Failed to create custom skill') + } + + const data = await response.json() + console.log('Custom skill created successfully:', data.skill) + + // TODO: Reload skills list to include new custom skill + // For now, just notify the user + alert(`Custom skill "${config.name}" created successfully!`) + } catch (error) { + console.error('Failed to create custom skill:', error) + alert('Failed to create custom skill. Please try again.') + } + } + // Mixed mode: Show both skills if (isMixedMode) { if (!currentAdditionSkill || !currentSubtractionSkill) { @@ -920,14 +1003,65 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master marginTop: '1rem', display: 'flex', gap: '0.75rem', + flexWrap: 'wrap', })} > + + + +
) } diff --git a/apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.stories.tsx b/apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.stories.tsx new file mode 100644 index 00000000..97c36199 --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.stories.tsx @@ -0,0 +1,138 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { SkillConfigurationModal } from './SkillConfigurationModal' +import { DIFFICULTY_PROFILES } from '../../difficultyProfiles' + +const meta = { + title: 'Worksheets/Config Panel/SkillConfigurationModal', + component: SkillConfigurationModal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +// Wrapper component to manage modal state +function ModalWrapper(args: React.ComponentProps) { + const [open, setOpen] = useState(true) + + return ( + <> + + setOpen(false)} /> + + ) +} + +export const CreateMode: Story = { + render: (args) => , + args: { + mode: 'create', + operator: 'addition', + onSave: (config) => { + console.log('Created skill:', config) + }, + }, +} + +export const EditModeEarlyLearner: Story = { + render: (args) => , + args: { + mode: 'edit', + operator: 'addition', + existingConfig: { + name: 'Early Learner Addition', + description: 'Simple addition with lots of scaffolding', + digitRange: { min: 2, max: 2 }, + regroupingConfig: DIFFICULTY_PROFILES.earlyLearner.regrouping, + displayRules: DIFFICULTY_PROFILES.earlyLearner.displayRules, + }, + onSave: (config) => { + console.log('Updated skill:', config) + }, + }, +} + +export const EditModeIntermediate: Story = { + render: (args) => , + args: { + mode: 'edit', + operator: 'addition', + existingConfig: { + name: 'Intermediate Addition', + description: 'Moderate difficulty with some scaffolding', + digitRange: { min: 2, max: 3 }, + regroupingConfig: DIFFICULTY_PROFILES.intermediate.regrouping, + displayRules: DIFFICULTY_PROFILES.intermediate.displayRules, + }, + onSave: (config) => { + console.log('Updated skill:', config) + }, + }, +} + +export const EditModeExpert: Story = { + render: (args) => , + args: { + mode: 'edit', + operator: 'subtraction', + existingConfig: { + name: 'Expert Subtraction', + description: 'Advanced subtraction with minimal support', + digitRange: { min: 3, max: 4 }, + regroupingConfig: DIFFICULTY_PROFILES.expert.regrouping, + displayRules: DIFFICULTY_PROFILES.expert.displayRules, + }, + onSave: (config) => { + console.log('Updated skill:', config) + }, + }, +} + +export const CustomConfiguration: Story = { + render: (args) => , + args: { + mode: 'edit', + operator: 'addition', + existingConfig: { + name: 'Custom Skill', + description: 'My customized difficulty settings', + digitRange: { min: 2, max: 4 }, + regroupingConfig: { + pAnyStart: 0.5, + pAllStart: 0.1, + }, + displayRules: { + carryBoxes: 'sometimes', + answerBoxes: 'always', + placeValueColors: 'sometimes', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + }, + }, + onSave: (config) => { + console.log('Updated custom skill:', config) + }, + }, +} diff --git a/apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.tsx b/apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.tsx new file mode 100644 index 00000000..7b80b2f2 --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/config-panel/SkillConfigurationModal.tsx @@ -0,0 +1,382 @@ +'use client' + +import { useState } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { css } from '@styled/css' +import { stack } from '@styled/patterns' +import { useTheme } from '@/contexts/ThemeContext' +import { DifficultyPlot2D, type PlotPoint } from './DifficultyPlot2D' +import { DigitRangeSection } from './DigitRangeSection' +import type { DisplayRules } from '../../displayRules' + +export interface SkillConfig { + digitRange: { min: number; max: number } + regroupingConfig: { pAnyStart: number; pAllStart: number } + displayRules: DisplayRules +} + +export interface SkillConfigurationModalProps { + /** Whether modal is open */ + open: boolean + /** Callback when modal should close */ + onClose: () => void + /** Modal mode: 'create' for new skill, 'edit' for existing skill */ + mode: 'create' | 'edit' + /** Operator for the skill */ + operator: 'addition' | 'subtraction' + /** Existing skill configuration (for edit mode) */ + existingConfig?: SkillConfig & { name: string; description?: string } + /** Callback when save is clicked */ + onSave: (config: { + name: string + description?: string + digitRange: { min: number; max: number } + regroupingConfig: { pAnyStart: number; pAllStart: number } + displayRules: DisplayRules + }) => void + /** Other skills in mastery progression to plot (for edit mode) */ + masteryProgressionSkills?: PlotPoint[] +} + +/** + * Modal for configuring custom skills or editing existing skills + * Uses the 2D difficulty plot and digit range slider + */ +export function SkillConfigurationModal({ + open, + onClose, + mode, + operator, + existingConfig, + onSave, + masteryProgressionSkills, +}: SkillConfigurationModalProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + + // Form state + const [name, setName] = useState(existingConfig?.name ?? '') + const [description, setDescription] = useState(existingConfig?.description ?? '') + const [digitRange, setDigitRange] = useState(existingConfig?.digitRange ?? { min: 2, max: 2 }) + const [regroupingConfig, setRegroupingConfig] = useState( + existingConfig?.regroupingConfig ?? { pAnyStart: 0.25, pAllStart: 0 } + ) + const [displayRules, setDisplayRules] = useState( + existingConfig?.displayRules ?? { + carryBoxes: 'never', + answerBoxes: 'always', + placeValueColors: 'never', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + } + ) + + const handleDifficultyChange = (config: { + pAnyStart: number + pAllStart: number + displayRules: DisplayRules + }) => { + setRegroupingConfig({ pAnyStart: config.pAnyStart, pAllStart: config.pAllStart }) + setDisplayRules(config.displayRules) + } + + const handleSave = () => { + if (!name.trim()) { + alert('Please enter a skill name') + return + } + + onSave({ + name: name.trim(), + description: description.trim() || undefined, + digitRange, + regroupingConfig, + displayRules, + }) + onClose() + } + + return ( + !isOpen && onClose()}> + + + + {/* Header */} +
+ + {mode === 'create' ? 'Create Custom Skill' : 'Configure Skill'} + + + {mode === 'create' + ? 'Create a new skill with custom difficulty settings' + : 'Modify the difficulty settings for this skill'} + +
+ + {/* Form */} +
+ {/* Name Field */} +
+ + setName(e.target.value)} + placeholder="e.g., Advanced Two-Digit Addition" + className={css({ + px: '3', + py: '2', + border: '1px solid', + borderColor: isDark ? 'gray.600' : 'gray.300', + borderRadius: 'md', + bg: isDark ? 'gray.700' : 'white', + color: isDark ? 'gray.100' : 'gray.900', + fontSize: 'sm', + _focus: { + outline: 'none', + borderColor: isDark ? 'blue.500' : 'blue.600', + ring: '2px', + ringColor: isDark ? 'blue.500/20' : 'blue.600/20', + }, + })} + /> +
+ + {/* Description Field */} +
+ +