feat: add skill configuration system with interactive 2D difficulty plot
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 <noreply@anthropic.com>
This commit is contained in:
parent
bad25d24a2
commit
7fbc743c4c
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,4 +164,4 @@
|
|||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof DifficultyPlot2D>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
@ -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<PlotPoint | null>(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<SVGSVGElement>) => {
|
||||
// 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<SVGSVGElement>) => {
|
||||
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 (
|
||||
<div
|
||||
data-component="difficulty-plot-2d"
|
||||
className={css({
|
||||
position: 'relative',
|
||||
w: 'full',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
bg: isDark ? 'gray.900' : 'white',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={css({
|
||||
maxWidth: `${maxSize}px`,
|
||||
cursor: 'crosshair',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{[0, 2, 4, 6, 8, 10].map((val) => (
|
||||
<g key={`grid-${val}`}>
|
||||
<line
|
||||
x1={toX(val)}
|
||||
y1={padding}
|
||||
x2={toX(val)}
|
||||
y2={height - padding}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3,3"
|
||||
/>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={toY(val)}
|
||||
x2={width - padding}
|
||||
y2={toY(val)}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3,3"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Axes */}
|
||||
<line
|
||||
x1={padding}
|
||||
y1={height - padding}
|
||||
x2={width - padding}
|
||||
y2={height - padding}
|
||||
stroke="#374151"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1={padding}
|
||||
y1={padding}
|
||||
x2={padding}
|
||||
y2={height - padding}
|
||||
stroke="#374151"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Axis labels */}
|
||||
<text
|
||||
x={width / 2}
|
||||
y={height - 10}
|
||||
textAnchor="middle"
|
||||
fontSize="13"
|
||||
fontWeight="500"
|
||||
fill="#4b5563"
|
||||
>
|
||||
Regrouping Intensity →
|
||||
</text>
|
||||
<text
|
||||
x={15}
|
||||
y={height / 2}
|
||||
textAnchor="middle"
|
||||
fontSize="13"
|
||||
fontWeight="500"
|
||||
fill="#4b5563"
|
||||
transform={`rotate(-90, 15, ${height / 2})`}
|
||||
>
|
||||
Scaffolding (more help) →
|
||||
</text>
|
||||
|
||||
{/* 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 (
|
||||
<g key={`edge-${point.id}-${nextPoint.id}`}>
|
||||
{/* Edge line */}
|
||||
<line
|
||||
x1={startX}
|
||||
y1={startY}
|
||||
x2={endX}
|
||||
y2={endY}
|
||||
stroke="#a855f7"
|
||||
strokeWidth="2"
|
||||
opacity="0.4"
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
{/* Arrow head */}
|
||||
<polygon
|
||||
points={`${endX},${endY} ${arrowPoint1X},${arrowPoint1Y} ${arrowPoint2X},${arrowPoint2Y}`}
|
||||
fill="#a855f7"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<g
|
||||
key={point.id}
|
||||
onMouseEnter={(e) => {
|
||||
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 */}
|
||||
<circle cx={toX(reg)} cy={toY(scaf)} r="15" fill="transparent" />
|
||||
{/* Visible skill circle */}
|
||||
<circle
|
||||
cx={toX(reg)}
|
||||
cy={toY(scaf)}
|
||||
r="10"
|
||||
fill="#9333ea"
|
||||
stroke="#7e22ce"
|
||||
strokeWidth="2"
|
||||
opacity={hoveredSkill?.id === point.id ? 1 : 0.7}
|
||||
/>
|
||||
{/* Ordinal number inside circle */}
|
||||
<text
|
||||
x={toX(reg)}
|
||||
y={toY(scaf)}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="11"
|
||||
fill="white"
|
||||
fontWeight="700"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{index + 1}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
// 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 (
|
||||
<g key={profileName}>
|
||||
<circle
|
||||
cx={toX(reg)}
|
||||
cy={toY(scaf)}
|
||||
r="5"
|
||||
fill="#6366f1"
|
||||
stroke="#4f46e5"
|
||||
strokeWidth="2"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<text
|
||||
x={toX(reg)}
|
||||
y={toY(scaf) - 10}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fill="#4338ca"
|
||||
fontWeight="600"
|
||||
>
|
||||
{p.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Hover preview - show where click will land */}
|
||||
{hoverPoint && (
|
||||
<>
|
||||
{/* Dashed line from hover to target */}
|
||||
<line
|
||||
x1={toX(hoverPoint.x)}
|
||||
y1={toY(hoverPoint.y)}
|
||||
x2={toX(currentReg)}
|
||||
y2={toY(currentScaf)}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
opacity="0.5"
|
||||
/>
|
||||
{/* Hover target marker */}
|
||||
<circle
|
||||
cx={toX(hoverPoint.x)}
|
||||
cy={toY(hoverPoint.y)}
|
||||
r="10"
|
||||
fill="#f59e0b"
|
||||
stroke="#d97706"
|
||||
strokeWidth="3"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<circle cx={toX(hoverPoint.x)} cy={toY(hoverPoint.y)} r="4" fill="white" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Current position */}
|
||||
<circle
|
||||
cx={toX(currentReg)}
|
||||
cy={toY(currentScaf)}
|
||||
r="8"
|
||||
fill="#10b981"
|
||||
stroke="#059669"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle cx={toX(currentReg)} cy={toY(currentScaf)} r="3" fill="white" />
|
||||
</svg>
|
||||
|
||||
{/* Hover tooltip for skill information */}
|
||||
{hoveredSkill && pointsToPlot && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '10px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bg: isDark ? 'purple.900' : 'purple.50',
|
||||
border: '2px solid',
|
||||
borderColor: isDark ? 'purple.700' : 'purple.300',
|
||||
borderRadius: 'lg',
|
||||
px: '4',
|
||||
py: '2',
|
||||
boxShadow: 'lg',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
maxWidth: '300px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'purple.400' : 'purple.600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'wider',
|
||||
mb: '1',
|
||||
})}
|
||||
>
|
||||
Skill {pointsToPlot.findIndex((p) => p.id === hoveredSkill.id) + 1}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'purple.100' : 'purple.900',
|
||||
})}
|
||||
>
|
||||
{hoveredSkill.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend for custom points */}
|
||||
{pointsToPlot && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: 'md',
|
||||
px: '3',
|
||||
py: '2',
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
boxShadow: 'sm',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
mb: '1',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '20px',
|
||||
h: '20px',
|
||||
borderRadius: 'full',
|
||||
bg: '#9333ea',
|
||||
border: '2px solid #7e22ce',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
color: 'white',
|
||||
fontWeight: '700',
|
||||
})}
|
||||
>
|
||||
#
|
||||
</div>
|
||||
<span>Skills in progression</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '20px',
|
||||
h: '20px',
|
||||
borderRadius: 'full',
|
||||
bg: '#10b981',
|
||||
border: '2px solid #059669',
|
||||
})}
|
||||
/>
|
||||
<span>Current configuration</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
mt: '2',
|
||||
pt: '2',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
fontSize: '10px',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
Hover skills for details
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<WorksheetFormState>)
|
||||
|
||||
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',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="configure-skill"
|
||||
onClick={() => setIsConfigureModalOpen(true)}
|
||||
className={css({
|
||||
flex: '1 1 auto',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'blue.600' : 'blue.500',
|
||||
backgroundColor: isDark ? 'blue.700' : 'blue.50',
|
||||
color: isDark ? 'blue.200' : 'blue.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: isDark ? 'blue.500' : 'blue.600',
|
||||
backgroundColor: isDark ? 'blue.600' : 'blue.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
⚙️ Configure Skill
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="create-custom-skill"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className={css({
|
||||
flex: '1 1 auto',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'green.600' : 'green.500',
|
||||
backgroundColor: isDark ? 'green.700' : 'green.50',
|
||||
color: isDark ? 'green.200' : 'green.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: isDark ? 'green.500' : 'green.600',
|
||||
backgroundColor: isDark ? 'green.600' : 'green.100',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✨ Create Custom Skill
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="view-all-skills"
|
||||
onClick={() => setIsAllSkillsModalOpen(true)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
flex: '1 1 auto',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
|
|
@ -1059,6 +1193,38 @@ export function MasteryModePanel({ formState, onChange, isDark = false }: Master
|
|||
}}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Configure Skill Modal */}
|
||||
<SkillConfigurationModal
|
||||
open={isConfigureModalOpen}
|
||||
onClose={() => setIsConfigureModalOpen(false)}
|
||||
mode="edit"
|
||||
operator={operator === 'mixed' ? 'addition' : operator}
|
||||
existingConfig={{
|
||||
name: currentSkill.name,
|
||||
description: currentSkill.description,
|
||||
digitRange: currentSkill.digitRange,
|
||||
regroupingConfig: currentSkill.regroupingConfig,
|
||||
displayRules: currentSkill.recommendedScaffolding,
|
||||
}}
|
||||
onSave={handleSaveCustomization}
|
||||
masteryProgressionSkills={availableSkills.map((skill) => ({
|
||||
id: skill.id,
|
||||
label: skill.name.split(' ').slice(0, 2).join(' '), // Shorten labels
|
||||
pAnyStart: skill.regroupingConfig.pAnyStart,
|
||||
pAllStart: skill.regroupingConfig.pAllStart,
|
||||
displayRules: skill.recommendedScaffolding,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Create Custom Skill Modal */}
|
||||
<SkillConfigurationModal
|
||||
open={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
mode="create"
|
||||
operator={operator === 'mixed' ? 'addition' : operator}
|
||||
onSave={handleCreateCustomSkill}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof SkillConfigurationModal>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Wrapper component to manage modal state
|
||||
function ModalWrapper(args: React.ComponentProps<typeof SkillConfigurationModal>) {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Open Modal
|
||||
</button>
|
||||
<SkillConfigurationModal {...args} open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const CreateMode: Story = {
|
||||
render: (args) => <ModalWrapper {...args} />,
|
||||
args: {
|
||||
mode: 'create',
|
||||
operator: 'addition',
|
||||
onSave: (config) => {
|
||||
console.log('Created skill:', config)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const EditModeEarlyLearner: Story = {
|
||||
render: (args) => <ModalWrapper {...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) => <ModalWrapper {...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) => <ModalWrapper {...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) => <ModalWrapper {...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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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<DisplayRules>(
|
||||
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 (
|
||||
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
bg: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 50,
|
||||
})}
|
||||
/>
|
||||
<Dialog.Content
|
||||
data-component="skill-configuration-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: 'xl',
|
||||
boxShadow: 'xl',
|
||||
p: '6',
|
||||
maxWidth: '650px',
|
||||
width: '90vw',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: 51,
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={stack({ gap: '2', mb: '5' })}>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
{mode === 'create' ? 'Create Custom Skill' : 'Configure Skill'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{mode === 'create'
|
||||
? 'Create a new skill with custom difficulty settings'
|
||||
: 'Modify the difficulty settings for this skill'}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className={stack({ gap: '5' })}>
|
||||
{/* Name Field */}
|
||||
<div className={stack({ gap: '2' })}>
|
||||
<label
|
||||
htmlFor="skill-name"
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
Skill Name *
|
||||
</label>
|
||||
<input
|
||||
id="skill-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description Field */}
|
||||
<div className={stack({ gap: '2' })}>
|
||||
<label
|
||||
htmlFor="skill-description"
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="skill-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what students will practice in this skill..."
|
||||
rows={3}
|
||||
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',
|
||||
resize: 'vertical',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: isDark ? 'blue.500' : 'blue.600',
|
||||
ring: '2px',
|
||||
ringColor: isDark ? 'blue.500/20' : 'blue.600/20',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Digit Range */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
Number of Digits
|
||||
</div>
|
||||
<DigitRangeSection
|
||||
digitRange={digitRange}
|
||||
onChange={(newDigitRange) => setDigitRange(newDigitRange)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2D Difficulty Plot */}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
Difficulty Configuration
|
||||
</div>
|
||||
<DifficultyPlot2D
|
||||
pAnyStart={regroupingConfig.pAnyStart}
|
||||
pAllStart={regroupingConfig.pAllStart}
|
||||
displayRules={displayRules}
|
||||
onChange={handleDifficultyChange}
|
||||
isDark={isDark}
|
||||
customPoints={masteryProgressionSkills}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
mt: '2',
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Click on the plot to select a difficulty configuration
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Configuration Summary */}
|
||||
<div
|
||||
className={css({
|
||||
p: '3',
|
||||
bg: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.400' : 'gray.500',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'wider',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Current Configuration
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<strong>Digits:</strong>{' '}
|
||||
{digitRange.min === digitRange.max
|
||||
? digitRange.min
|
||||
: `${digitRange.min}-${digitRange.max}`}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Regrouping:</strong> {Math.round(regroupingConfig.pAnyStart * 100)}%
|
||||
</div>
|
||||
<div>
|
||||
<strong>Operator:</strong> {operator}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '3',
|
||||
justifyContent: 'flex-end',
|
||||
mt: '6',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
data-action="cancel"
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
bg: 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.700' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
data-action="save-skill"
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'white',
|
||||
bg: isDark ? 'blue.600' : 'blue.700',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isDark ? 'blue.700' : 'blue.800',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{mode === 'create' ? 'Create Skill' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue