fix: correct GPT-5 API parameters and surface actual grading errors
Two critical fixes for worksheet grading: 1. **Fix OpenAI Responses API parameters** - Move `verbosity` from top-level to `text.verbosity` - API was rejecting requests with 400 error - Confirmed against GPT-5 Responses API documentation 2. **Surface actual grading errors in UI** - Add `error_message` column to worksheet_attempts table - Store actual API/grading errors in database - Display real error messages instead of generic "too blurry" text - Users now see OpenAI API errors, validation failures, etc. Changes: - Updated gradeWorksheet.ts API call structure - Created migration 0020 for error_message column - Updated processAttempt.ts to save error messages - Updated API route to return errorMessage field - Updated results page to display actual errors Now when grading fails, users see helpful error messages like: "Unsupported parameter: 'verbosity'..." instead of just "too blurry" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
@@ -10,7 +10,14 @@
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git checkout:*)"
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(pnpm remove:*)",
|
||||
"Bash(git rm:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:platform.openai.com)",
|
||||
"Bash(git rev-parse:*)",
|
||||
"Bash(npx drizzle-kit:*)",
|
||||
"Bash(npm run db:migrate:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
BIN
apps/web/data/uploads/02afe674-52fc-4a14-a3ba-0924193bc306.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/web/data/uploads/02b9d135-683a-4194-ac28-cead86501bab.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/data/uploads/151ddae9-c1a3-4f6b-8d44-74d31e7c7a18.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/web/data/uploads/23c86bbf-1a1e-47f7-82f8-42998f1fe259.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/data/uploads/33c15313-a93e-4377-b5da-b4fae571d7f5.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web/data/uploads/75f2b9f2-26f1-45e5-9967-ca37c9ff4f3d.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
apps/web/data/uploads/99247aab-49c3-40ed-aa07-273b9ef9a332.jpg
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
apps/web/data/uploads/b12357c2-d921-479f-ba7e-eee579dc01e5.jpg
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/web/data/uploads/b5ada810-9faf-4cfd-b6dc-5e48ae482335.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
4
apps/web/drizzle/0015_early_pepper_potts.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
CREATE TABLE `worksheet_mastery` (`id` text PRIMARY KEY NOT NULL, `user_id` text NOT NULL, `skill_id` text NOT NULL, `is_mastered` integer DEFAULT 0 NOT NULL, `total_attempts` integer DEFAULT 0 NOT NULL, `correct_attempts` integer DEFAULT 0 NOT NULL, `last_accuracy` real, `first_attempt_at` integer, `mastered_at` integer, `last_practiced_at` integer NOT NULL, `updated_at` integer NOT NULL, `created_at` integer NOT NULL, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `worksheet_mastery_user_skill_idx` ON `worksheet_mastery` (`user_id`, `skill_id`);
|
||||
14
apps/web/drizzle/0016_confused_the_enforcers.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Custom SQL migration file for worksheet attempts and problem attempts tables
|
||||
CREATE TABLE `worksheet_attempts` (`id` text PRIMARY KEY NOT NULL, `user_id` text NOT NULL, `uploaded_image_url` text NOT NULL, `worksheet_id` text, `operator` text NOT NULL, `digit_count` integer NOT NULL, `problem_count` integer NOT NULL, `grading_status` text DEFAULT 'pending' NOT NULL, `graded_at` integer, `total_problems` integer, `correct_count` integer, `accuracy` real, `error_patterns` text, `suggested_step_id` text, `ai_feedback` text, `ai_response_raw` text, `created_at` integer NOT NULL, `updated_at` integer NOT NULL, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `problem_attempts` (`id` text PRIMARY KEY NOT NULL, `attempt_id` text NOT NULL, `user_id` text NOT NULL, `problem_index` integer NOT NULL, `operand_a` integer NOT NULL, `operand_b` integer NOT NULL, `operator` text NOT NULL, `correct_answer` integer NOT NULL, `student_answer` integer, `student_work_digits` text, `is_correct` integer NOT NULL, `error_type` text, `digit_count` integer NOT NULL, `requires_regrouping` integer NOT NULL, `regroups_in_places` text, `created_at` integer NOT NULL, FOREIGN KEY (`attempt_id`) REFERENCES `worksheet_attempts`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `worksheet_attempts_user_idx` ON `worksheet_attempts` (`user_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `worksheet_attempts_status_idx` ON `worksheet_attempts` (`grading_status`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `problem_attempts_attempt_idx` ON `problem_attempts` (`attempt_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `problem_attempts_user_idx` ON `problem_attempts` (`user_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `problem_attempts_type_idx` ON `problem_attempts` (`user_id`, `digit_count`, `requires_regrouping`);
|
||||
4
apps/web/drizzle/0020_supreme_saracen.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
|
||||
-- Add error_message column to worksheet_attempts for storing grading failure details
|
||||
ALTER TABLE `worksheet_attempts` ADD `error_message` text;
|
||||
1038
apps/web/drizzle/meta/0015_snapshot.json
Normal file
1038
apps/web/drizzle/meta/0016_snapshot.json
Normal file
1094
apps/web/drizzle/meta/0020_snapshot.json
Normal file
@@ -141,6 +141,13 @@
|
||||
"when": 1762744536759,
|
||||
"tag": "0019_broad_vance_astro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1762776068979,
|
||||
"tag": "0020_supreme_saracen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export async function GET(request: NextRequest, { params }: { params: { attemptI
|
||||
status: attempt.gradingStatus,
|
||||
uploadedAt: attempt.createdAt,
|
||||
gradedAt: attempt.gradedAt,
|
||||
errorMessage: attempt.errorMessage,
|
||||
|
||||
// Results summary
|
||||
totalProblems: attempt.totalProblems,
|
||||
|
||||
150
apps/web/src/app/api/worksheets/mastery/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { SkillId } from '@/app/create/worksheets/addition/skills'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
/**
|
||||
* GET /api/worksheets/mastery?operator=addition
|
||||
* Load user's mastery states for all skills
|
||||
*
|
||||
* Query params:
|
||||
* - operator: 'addition' | 'subtraction'
|
||||
*
|
||||
* Returns:
|
||||
* - masteryStates: Array of mastery records
|
||||
* - skillCount: Total number of skills tracked
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const { searchParams } = new URL(req.url)
|
||||
const operator = searchParams.get('operator')
|
||||
|
||||
if (!operator) {
|
||||
return NextResponse.json({ error: 'Missing operator parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (operator !== 'addition' && operator !== 'subtraction') {
|
||||
return NextResponse.json({ error: `Invalid operator: ${operator}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch all mastery records for this user
|
||||
// Note: We don't filter by operator here because skill IDs are already namespaced
|
||||
// (e.g., "sd-no-regroup" for addition, "sd-sub-no-borrow" for subtraction)
|
||||
const masteryRecords = await db
|
||||
.select()
|
||||
.from(schema.worksheetMastery)
|
||||
.where(eq(schema.worksheetMastery.userId, viewerId))
|
||||
|
||||
return NextResponse.json({
|
||||
masteryStates: masteryRecords,
|
||||
skillCount: masteryRecords.length,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load mastery states:', error)
|
||||
return NextResponse.json({ error: 'Failed to load mastery states' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/worksheets/mastery
|
||||
* Update mastery state for a skill
|
||||
*
|
||||
* Body:
|
||||
* - skillId: string (e.g., "td-ones-regroup")
|
||||
* - isMastered: boolean
|
||||
* - totalAttempts?: number (optional)
|
||||
* - correctAttempts?: number (optional)
|
||||
* - lastAccuracy?: number (optional, 0.0-1.0)
|
||||
*
|
||||
* Returns:
|
||||
* - success: boolean
|
||||
* - masteryState: Updated mastery record
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
const { skillId, isMastered, totalAttempts, correctAttempts, lastAccuracy } = body
|
||||
|
||||
if (!skillId) {
|
||||
return NextResponse.json({ error: 'Missing skillId field' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (typeof isMastered !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing or invalid isMastered field (must be boolean)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user already has mastery record for this skill
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.worksheetMastery)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.worksheetMastery.userId, viewerId),
|
||||
eq(schema.worksheetMastery.skillId, skillId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (existing) {
|
||||
// Update existing record
|
||||
const updated = {
|
||||
isMastered,
|
||||
totalAttempts: totalAttempts !== undefined ? totalAttempts : existing.totalAttempts,
|
||||
correctAttempts: correctAttempts !== undefined ? correctAttempts : existing.correctAttempts,
|
||||
lastAccuracy: lastAccuracy !== undefined ? lastAccuracy : existing.lastAccuracy,
|
||||
masteredAt: isMastered ? existing.masteredAt || now : null, // Set mastered timestamp on first mastery
|
||||
lastPracticedAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.worksheetMastery)
|
||||
.set(updated)
|
||||
.where(eq(schema.worksheetMastery.id, existing.id))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
masteryState: {
|
||||
...existing,
|
||||
...updated,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Insert new record
|
||||
const id = crypto.randomUUID()
|
||||
const newRecord = {
|
||||
id,
|
||||
userId: viewerId,
|
||||
skillId: skillId as SkillId,
|
||||
isMastered,
|
||||
totalAttempts: totalAttempts || 0,
|
||||
correctAttempts: correctAttempts || 0,
|
||||
lastAccuracy: lastAccuracy || null,
|
||||
firstAttemptAt: now,
|
||||
masteredAt: isMastered ? now : null,
|
||||
lastPracticedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(schema.worksheetMastery).values(newRecord)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
masteryState: newRecord,
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update mastery state:', error)
|
||||
return NextResponse.json({ error: 'Failed to update mastery state' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
# AI-Powered Worksheet Grading - Executive Summary
|
||||
|
||||
## The Vision
|
||||
|
||||
Teachers upload photos of students' completed worksheets. AI grades them automatically, identifies error patterns, and updates each student's mastery profile. The system then recommends targeted practice areas.
|
||||
|
||||
## User Flow
|
||||
|
||||
```
|
||||
1. Teacher prints worksheet from our generator
|
||||
2. Student completes worksheet by hand
|
||||
3. Teacher takes photo with phone/tablet
|
||||
4. Teacher uploads photo → AI grades in ~30 seconds
|
||||
5. Teacher sees results:
|
||||
- Score (17/20 = 85%)
|
||||
- Problem-by-problem breakdown with corrections
|
||||
- Error pattern analysis: "Student struggles with carrying in tens place"
|
||||
- Recommended next step in progression path
|
||||
6. Student's mastery profile auto-updates
|
||||
7. System suggests targeted worksheet focusing on weak areas
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### For Teachers
|
||||
- **No manual grading** - AI handles it automatically
|
||||
- **Instant insights** - See exactly where students struggle
|
||||
- **Targeted practice** - Auto-generate worksheets for weak areas
|
||||
- **Progress tracking** - Visual mastery progress over time
|
||||
|
||||
### For Students
|
||||
- **Personalized learning** - Practice problems target their specific needs
|
||||
- **Clear progression** - See mastery growth with visual feedback
|
||||
- **Encouragement** - AI provides positive, constructive feedback
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Database (Already Built!)
|
||||
- `worksheet_attempts` - Stores uploaded worksheets and grading results
|
||||
- `problem_attempts` - Tracks each individual problem result
|
||||
- Existing `worksheet_mastery` - Updates based on grading
|
||||
|
||||
### API Flow
|
||||
```
|
||||
POST /api/worksheets/upload
|
||||
→ Store image
|
||||
→ Queue for OCR processing
|
||||
→ Return attemptId
|
||||
|
||||
Background job:
|
||||
→ Extract text with Google Vision API ($1.50 per 1,000 images)
|
||||
→ Parse problems and answers
|
||||
→ Grade with Claude AI ($0.001-0.003 per worksheet)
|
||||
→ Store results
|
||||
→ Update mastery profile
|
||||
|
||||
GET /api/worksheets/attempts/:attemptId
|
||||
→ Return grading results
|
||||
→ Include AI feedback and recommendations
|
||||
```
|
||||
|
||||
### UI Components
|
||||
1. **Upload Modal** - Drag & drop or camera upload
|
||||
2. **Processing View** - "AI is grading your work..."
|
||||
3. **Results Page** - Score, corrections, feedback, next steps
|
||||
4. **Dashboard** - Recent attempts, progress charts
|
||||
|
||||
## Cost Estimates
|
||||
|
||||
### Per 1,000 Worksheets Graded:
|
||||
- **OCR (Google Vision)**: ~$1.50
|
||||
- **AI Grading (Claude Haiku)**: ~$1-3
|
||||
- **Storage (images)**: ~$0.15
|
||||
- **Total**: ~$3-5 per 1,000 worksheets
|
||||
|
||||
### Monthly for 100 Students:
|
||||
- ~10 worksheets/student/month = 1,000 worksheets
|
||||
- **Total cost**: ~$3-5/month for all students
|
||||
|
||||
**This is incredibly cheap for automated grading!**
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: MVP (2-3 weeks)
|
||||
- ✅ Database schema (DONE!)
|
||||
- Upload API with local file storage
|
||||
- Google Vision OCR integration
|
||||
- Claude AI grading
|
||||
- Basic results view
|
||||
|
||||
### Phase 2: Polish (1 week)
|
||||
- Enhanced UI/UX
|
||||
- Progress visualization
|
||||
- Targeted worksheet generation
|
||||
- Teacher dashboard
|
||||
|
||||
### Phase 3: Scale (1 week)
|
||||
- Move to cloud storage (Cloudflare R2)
|
||||
- Optimize OCR accuracy
|
||||
- Batch processing for classes
|
||||
- Parent/teacher reporting
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### OCR Service: Google Vision API
|
||||
**Why**:
|
||||
- Industry-leading accuracy
|
||||
- Handles handwritten text well
|
||||
- Reasonable pricing
|
||||
- Simple API
|
||||
|
||||
**Alternative**: Azure Computer Vision (similar quality/price)
|
||||
|
||||
### AI Grading: Claude (Anthropic)
|
||||
**Why**:
|
||||
- Excellent at analyzing patterns
|
||||
- Provides constructive feedback
|
||||
- Lower cost than GPT-4
|
||||
- We already use it elsewhere
|
||||
|
||||
### File Storage: Start Local, Move to R2
|
||||
**Why**:
|
||||
- Local storage for MVP (faster development)
|
||||
- Cloudflare R2 for production (cheap, fast)
|
||||
- Easy migration path
|
||||
|
||||
## What Could Go Wrong
|
||||
|
||||
### OCR Accuracy Issues
|
||||
- **Problem**: Messy handwriting hard to read
|
||||
- **Solution**: Allow teacher to correct OCR results manually
|
||||
- **Mitigation**: Mark uncertain problems, show confidence scores
|
||||
|
||||
### AI Grading Mistakes
|
||||
- **Problem**: AI might misinterpret student's work
|
||||
- **Solution**: Teacher can override AI grading
|
||||
- **Mitigation**: Show AI reasoning, allow feedback
|
||||
|
||||
### Cost Overruns
|
||||
- **Problem**: High usage could increase costs
|
||||
- **Solution**: Set usage limits per account
|
||||
- **Mitigation**: Cache OCR results, batch processing
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### MVP Success (Phase 1):
|
||||
- ✅ 90%+ OCR accuracy on printed worksheets
|
||||
- ✅ 85%+ grading accuracy vs teacher grading
|
||||
- ✅ <60 seconds average processing time
|
||||
- ✅ Teachers report time savings
|
||||
|
||||
### Long-term Success:
|
||||
- Students show measurable improvement in weak areas
|
||||
- Teachers use targeted recommendations regularly
|
||||
- High satisfaction scores from teachers
|
||||
- Low manual correction rate (<10%)
|
||||
|
||||
## Why This Is Valuable
|
||||
|
||||
### Current Reality:
|
||||
- Teachers spend hours grading worksheets manually
|
||||
- Hard to track individual student progress over time
|
||||
- Difficult to identify specific error patterns
|
||||
- One-size-fits-all practice doesn't help struggling students
|
||||
|
||||
### With AI Grading:
|
||||
- Instant grading, zero teacher time
|
||||
- Automatic mastery tracking per student
|
||||
- AI identifies: "This student forgets to carry in tens place"
|
||||
- Targeted worksheets focus practice where needed most
|
||||
|
||||
**This transforms worksheets from static practice to adaptive learning.**
|
||||
|
||||
## Next Steps to Start Implementation
|
||||
|
||||
1. **Confirm approach** - Teacher uploads paper worksheets (not interactive digital)
|
||||
2. **Set up Google Vision API** - Get API key, test with sample worksheets
|
||||
3. **Build upload API** - File handling, storage, queue management
|
||||
4. **Integrate Claude grading** - Prompt engineering for accurate analysis
|
||||
5. **Build UI** - Upload modal, results view
|
||||
6. **Test with real worksheets** - Tune OCR and AI prompts
|
||||
7. **Launch MVP** - Start with small group of teachers
|
||||
|
||||
## Timeline
|
||||
|
||||
- **Week 1**: Upload API + OCR integration
|
||||
- **Week 2**: AI grading + results storage
|
||||
- **Week 3**: UI components + testing
|
||||
- **Week 4**: Polish + teacher beta testing
|
||||
|
||||
**Ready to start in 4 weeks from kickoff.**
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Photo quality requirements?**
|
||||
- Minimum resolution? (Recommend: 1920×1080)
|
||||
- Lighting requirements?
|
||||
- Multiple pages per upload?
|
||||
|
||||
2. **Teacher workflow?**
|
||||
- Upload one worksheet at a time or batch?
|
||||
- Mobile-first or desktop?
|
||||
- Real-time grading or async queue?
|
||||
|
||||
3. **Grading tolerance?**
|
||||
- How strict on handwriting (6 vs 0)?
|
||||
- Accept alternative methods (different carrying notation)?
|
||||
- Partial credit for showing work?
|
||||
|
||||
4. **Privacy/security?**
|
||||
- Student names on worksheets?
|
||||
- Image retention policy?
|
||||
- FERPA compliance?
|
||||
|
||||
## Recommendation
|
||||
|
||||
**START WITH MVP**: Build upload → OCR → AI grading → results view.
|
||||
|
||||
Get it working with 90% accuracy, then iterate based on teacher feedback. This is a game-changing feature that will make the worksheet generator incredibly valuable for teachers.
|
||||
|
||||
**Estimated development time**: 3-4 weeks for working MVP.
|
||||
**Estimated cost**: ~$3-5 per 1,000 worksheets graded (negligible for most use cases).
|
||||
**Value**: Massive time savings for teachers + personalized learning for students.
|
||||
@@ -0,0 +1,536 @@
|
||||
# Mastery Dependencies & Auto-Advance System
|
||||
|
||||
## Auto-Advance Behavior (Question 2)
|
||||
|
||||
### When User Marks Skill as Mastered
|
||||
|
||||
**Two possible behaviors:**
|
||||
|
||||
#### Option A: Stay on Current Skill (Conservative)
|
||||
```
|
||||
User marks "td-ones-regroup" as mastered
|
||||
↓
|
||||
Update mastery state in database
|
||||
↓
|
||||
UI updates (checkmark, "Mastered on [date]")
|
||||
↓
|
||||
Worksheet generator STILL uses "td-ones-regroup" as current skill
|
||||
↓
|
||||
User must manually click "Practice This" on next skill OR click "Next Skill" button
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- User has explicit control
|
||||
- Can generate multiple worksheets at newly-mastered skill level
|
||||
- Good for teachers who want to verify mastery with multiple worksheets
|
||||
|
||||
**Cons:**
|
||||
- Requires extra click to advance
|
||||
- Less "guided" experience
|
||||
|
||||
#### Option B: Auto-Advance with Confirmation (Recommended)
|
||||
```
|
||||
User marks "td-ones-regroup" as mastered
|
||||
↓
|
||||
Update mastery state in database
|
||||
↓
|
||||
Show confirmation toast:
|
||||
"✓ Two-digit ones regrouping marked as mastered!
|
||||
Moving to: Two-digit mixed regrouping
|
||||
[Undo] [Stay Here]"
|
||||
↓
|
||||
After 5 seconds (or immediate if user dismisses):
|
||||
↓
|
||||
Update current skill to "td-mixed-regroup"
|
||||
↓
|
||||
Regenerate preview with new skill
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Smooth guided progression
|
||||
- User can undo or stay if needed
|
||||
- Encourages continuous learning
|
||||
|
||||
**Cons:**
|
||||
- Might surprise users who want to stay
|
||||
|
||||
#### **Recommended Implementation: Option B with Persistent "Stay Here" Option**
|
||||
|
||||
```typescript
|
||||
interface MasteryConfirmationToast {
|
||||
type: "mastery-advance";
|
||||
previousSkill: SkillDefinition;
|
||||
nextSkill: SkillDefinition;
|
||||
|
||||
actions: [
|
||||
{ label: "Undo Mastery", action: "undo" },
|
||||
{ label: "Stay Here", action: "stay" },
|
||||
// Auto-advance after 5s if no action
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**UI Flow:**
|
||||
|
||||
1. User clicks "Mark as Mastered" on skill
|
||||
2. Toast appears at top of screen:
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ ✓ Two-digit ones regrouping marked as mastered! │
|
||||
│ → Moving to: Two-digit mixed regrouping in 5s... │
|
||||
│ │
|
||||
│ [Undo Mastery] [Stay Here] [×] │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
3. Options:
|
||||
- **Do nothing**: Auto-advance after 5s
|
||||
- **Click "Undo Mastery"**: Revert mastery state, stay on current skill
|
||||
- **Click "Stay Here"**: Keep mastery state, stay on current skill (generate more practice worksheets)
|
||||
- **Click ×**: Dismiss and auto-advance immediately
|
||||
|
||||
4. After advancing:
|
||||
- Regenerate preview with new current skill
|
||||
- Show new mix breakdown
|
||||
- Update "All Skills" modal view
|
||||
|
||||
---
|
||||
|
||||
## Mastery Dependency Graph (Civilization-style Tech Tree)
|
||||
|
||||
### Concept: Skill Prerequisites as DAG (Directed Acyclic Graph)
|
||||
|
||||
Like Civilization's tech tree, skills have **dependencies** that must be mastered first. Some skills have **multiple paths** to unlock them.
|
||||
|
||||
### Dependency Structure
|
||||
|
||||
```typescript
|
||||
export interface SkillDefinition {
|
||||
id: SkillId;
|
||||
name: string;
|
||||
// ... other fields ...
|
||||
|
||||
// Prerequisites: Skills that MUST be mastered first
|
||||
prerequisites: SkillId[];
|
||||
|
||||
// Recommended review: Skills that should appear in review mix when practicing this skill
|
||||
// (subset of prerequisites + related skills)
|
||||
recommendedReview: SkillId[];
|
||||
}
|
||||
```
|
||||
|
||||
### Example Dependency Graph (Addition)
|
||||
|
||||
```
|
||||
Legend:
|
||||
→ Direct prerequisite
|
||||
⇢ Recommended review (not required)
|
||||
|
||||
Single-Digit Skills
|
||||
┌─────────────────────┐
|
||||
│ sd-no-regroup │
|
||||
│ (3+5, 2+4) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ sd-simple-regroup │
|
||||
│ (7+8, 9+6) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
↓
|
||||
Two-Digit Skills
|
||||
┌─────────────────────┐
|
||||
│ td-no-regroup │──────┐
|
||||
│ (23+45, 31+28) │ │
|
||||
└──────────┬──────────┘ │
|
||||
│ │
|
||||
↓ ↓
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ td-ones-regroup │ │ td-tens-only │
|
||||
│ (38+27, 49+15) │ │ (50+70, 30+80) │ (optional alt path)
|
||||
└──────────┬──────────┘ └──────────┬──────────┘
|
||||
│ │
|
||||
└──────────┬──────────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ td-mixed-regroup │
|
||||
│ (67+58, 84+73) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ td-full-regroup │
|
||||
│ (88+99, 76+67) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
↓
|
||||
Three-Digit Skills
|
||||
┌─────────────────────┐
|
||||
│ 3d-no-regroup │
|
||||
│ (234+451) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 3d-simple-regroup │──────┐
|
||||
│ (367+258) │ │
|
||||
└──────────┬──────────┘ │
|
||||
│ │
|
||||
└──────────┬──────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 3d-full-regroup │
|
||||
│ (888+999) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
↓
|
||||
Four/Five-Digit Skills
|
||||
┌─────────────────────┐
|
||||
│ 4d-mastery │
|
||||
│ (3847+2956) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 5d-mastery │
|
||||
│ (38472+29563) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Detailed Dependencies Definition
|
||||
|
||||
```typescript
|
||||
export const SKILL_DEPENDENCIES: Record<SkillId, {
|
||||
prerequisites: SkillId[];
|
||||
recommendedReview: SkillId[];
|
||||
}> = {
|
||||
// Single-digit
|
||||
"sd-no-regroup": {
|
||||
prerequisites: [],
|
||||
recommendedReview: [],
|
||||
},
|
||||
"sd-simple-regroup": {
|
||||
prerequisites: ["sd-no-regroup"],
|
||||
recommendedReview: ["sd-no-regroup"],
|
||||
},
|
||||
|
||||
// Two-digit
|
||||
"td-no-regroup": {
|
||||
prerequisites: ["sd-simple-regroup"],
|
||||
recommendedReview: ["sd-simple-regroup"], // Still practice single-digit
|
||||
},
|
||||
"td-ones-regroup": {
|
||||
prerequisites: ["td-no-regroup"],
|
||||
recommendedReview: ["sd-simple-regroup", "td-no-regroup"], // Related: sd regrouping + td alignment
|
||||
},
|
||||
"td-mixed-regroup": {
|
||||
prerequisites: ["td-ones-regroup"],
|
||||
recommendedReview: ["td-no-regroup", "td-ones-regroup"], // Focus on recent prerequisites
|
||||
},
|
||||
"td-full-regroup": {
|
||||
prerequisites: ["td-mixed-regroup"],
|
||||
recommendedReview: ["td-ones-regroup", "td-mixed-regroup"], // Most recent skills
|
||||
},
|
||||
|
||||
// Three-digit
|
||||
"3d-no-regroup": {
|
||||
prerequisites: ["td-full-regroup"], // Must master 2-digit completely first
|
||||
recommendedReview: ["td-mixed-regroup", "td-full-regroup"], // Keep 2-digit sharp
|
||||
},
|
||||
"3d-simple-regroup": {
|
||||
prerequisites: ["3d-no-regroup"],
|
||||
recommendedReview: ["td-full-regroup", "3d-no-regroup"], // Mix 2-digit and 3-digit
|
||||
},
|
||||
"3d-full-regroup": {
|
||||
prerequisites: ["3d-simple-regroup"],
|
||||
recommendedReview: ["3d-no-regroup", "3d-simple-regroup"], // Recent 3-digit only
|
||||
},
|
||||
|
||||
// Four/five-digit
|
||||
"4d-mastery": {
|
||||
prerequisites: ["3d-full-regroup"],
|
||||
recommendedReview: ["3d-simple-regroup", "3d-full-regroup"], // Keep 3-digit fresh
|
||||
},
|
||||
"5d-mastery": {
|
||||
prerequisites: ["4d-mastery"],
|
||||
recommendedReview: ["3d-full-regroup", "4d-mastery"], // High-level review only
|
||||
},
|
||||
|
||||
// Subtraction follows similar pattern...
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Selection Algorithm: "Recently Mastered" Strategy
|
||||
|
||||
### Definition of "Recently Mastered"
|
||||
|
||||
**Recency window**: Skills mastered in the last N days, where N depends on skill level:
|
||||
- Early skills (sd-*, td-no-regroup): 30 days
|
||||
- Intermediate skills (td-*): 21 days
|
||||
- Advanced skills (3d-*, 4d-*): 14 days
|
||||
- Expert skills (5d-*): 7 days
|
||||
|
||||
**Rationale**: As students progress, they need tighter review cycles to maintain proficiency at higher levels.
|
||||
|
||||
### Selection Algorithm
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Select review skills based on recency and recommended review list
|
||||
*/
|
||||
function selectReviewSkills(
|
||||
currentSkill: SkillDefinition,
|
||||
masteryStates: Map<SkillId, MasteryState>,
|
||||
currentDate: Date = new Date()
|
||||
): SkillId[] {
|
||||
const masteredSkills = Array.from(masteryStates.entries())
|
||||
.filter(([_, state]) => state.isMastered)
|
||||
.map(([id, state]) => ({ id, state }));
|
||||
|
||||
if (masteredSkills.length === 0) {
|
||||
return []; // No review skills available
|
||||
}
|
||||
|
||||
// Step 1: Filter by recency window
|
||||
const recentlyMasteredSkills = masteredSkills.filter(({ id, state }) => {
|
||||
if (!state.masteredAt) return false;
|
||||
|
||||
const skill = SKILL_DEFINITIONS.find(s => s.id === id);
|
||||
const recencyWindow = getRecencyWindowForSkill(skill);
|
||||
const daysSinceMastery = differenceInDays(currentDate, state.masteredAt);
|
||||
|
||||
return daysSinceMastery <= recencyWindow;
|
||||
});
|
||||
|
||||
// Step 2: Prioritize skills from recommendedReview list
|
||||
const recommendedIds = new Set(currentSkill.recommendedReview);
|
||||
|
||||
const reviewCandidates = recentlyMasteredSkills
|
||||
.map(({ id }) => ({
|
||||
id,
|
||||
isRecommended: recommendedIds.has(id),
|
||||
masteredAt: masteryStates.get(id)!.masteredAt!,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Sort by: recommended first, then by recency
|
||||
if (a.isRecommended && !b.isRecommended) return -1;
|
||||
if (!a.isRecommended && b.isRecommended) return 1;
|
||||
return b.masteredAt.getTime() - a.masteredAt.getTime(); // Most recent first
|
||||
});
|
||||
|
||||
// Step 3: Select top N skills (max 3-4 for variety)
|
||||
const maxReviewSkills = Math.min(4, reviewCandidates.length);
|
||||
return reviewCandidates.slice(0, maxReviewSkills).map(c => c.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recency window in days based on skill level
|
||||
*/
|
||||
function getRecencyWindowForSkill(skill: SkillDefinition): number {
|
||||
if (skill.id.startsWith("sd-")) return 30; // Single-digit: 30 days
|
||||
if (skill.id.startsWith("td-")) return 21; // Two-digit: 21 days
|
||||
if (skill.id.startsWith("3d-")) return 14; // Three-digit: 14 days
|
||||
if (skill.id.startsWith("4d-") || skill.id.startsWith("5d-")) return 7; // Expert: 7 days
|
||||
return 21; // Default: 21 days
|
||||
}
|
||||
```
|
||||
|
||||
### Review Distribution
|
||||
|
||||
Once review skills are selected, distribute problems proportionally:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Distribute review problems across selected review skills
|
||||
*/
|
||||
function distributeReviewProblems(
|
||||
reviewSkills: SkillId[],
|
||||
totalReviewCount: number,
|
||||
rng: SeededRandom
|
||||
): Map<SkillId, number> {
|
||||
if (reviewSkills.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const distribution = new Map<SkillId, number>();
|
||||
|
||||
if (reviewSkills.length === 1) {
|
||||
// Only one review skill: give it all review problems
|
||||
distribution.set(reviewSkills[0], totalReviewCount);
|
||||
return distribution;
|
||||
}
|
||||
|
||||
// Multiple review skills: distribute evenly with slight randomness
|
||||
const baseCount = Math.floor(totalReviewCount / reviewSkills.length);
|
||||
const remainder = totalReviewCount % reviewSkills.length;
|
||||
|
||||
reviewSkills.forEach((skillId, index) => {
|
||||
const count = baseCount + (index < remainder ? 1 : 0);
|
||||
distribution.set(skillId, count);
|
||||
});
|
||||
|
||||
return distribution;
|
||||
}
|
||||
```
|
||||
|
||||
### Example Review Selection
|
||||
|
||||
**Scenario**: Student is practicing "td-mixed-regroup" (two-digit mixed regrouping)
|
||||
|
||||
**Mastery state**:
|
||||
- ✓ sd-no-regroup (mastered 45 days ago)
|
||||
- ✓ sd-simple-regroup (mastered 40 days ago)
|
||||
- ✓ td-no-regroup (mastered 25 days ago)
|
||||
- ✓ td-ones-regroup (mastered 3 days ago)
|
||||
|
||||
**Current skill's recommendedReview**: ["td-no-regroup", "td-ones-regroup"]
|
||||
|
||||
**Selection process**:
|
||||
1. Filter by recency (21 days for td-* skills):
|
||||
- ~~sd-no-regroup~~ (45 days, outside window)
|
||||
- ~~sd-simple-regroup~~ (40 days, outside window)
|
||||
- ✓ td-no-regroup (25 days, but will be included if needed)
|
||||
- ✓ td-ones-regroup (3 days, very recent)
|
||||
|
||||
2. Prioritize recommended:
|
||||
- td-ones-regroup (recommended + recent = top priority)
|
||||
- td-no-regroup (recommended but older)
|
||||
|
||||
3. **Selected review skills**: ["td-ones-regroup", "td-no-regroup"]
|
||||
|
||||
4. **Distribution** (5 review problems):
|
||||
- td-ones-regroup: 3 problems (60%)
|
||||
- td-no-regroup: 2 problems (40%)
|
||||
|
||||
---
|
||||
|
||||
## Dependency Visualization in UI
|
||||
|
||||
### Tech Tree Modal (Civilization-style)
|
||||
|
||||
**New Modal**: "Skill Tech Tree" (accessible from "View All Skills")
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Skill Progression - Addition Tech Tree × │
|
||||
│ ────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Single-Digit │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ No Regroup ✓ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ↓ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Regroup ✓ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ Two-Digit │ │
|
||||
│ ↓ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ No Regroup ✓ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ↓ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Ones Place ⭐│ ← You are here │
|
||||
│ │ Regrouping │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ↓ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Mixed │ ← Next skill │
|
||||
│ │ Regrouping ○ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ... (continues) │
|
||||
│ │
|
||||
│ Progress: 4/11 skills mastered │
|
||||
│ [Close] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation**: Use graphviz-style layout or simple vertical flow
|
||||
|
||||
---
|
||||
|
||||
## Config Schema Update (V5) - Refined
|
||||
|
||||
```typescript
|
||||
export const additionConfigV5SmartSchema = z.object({
|
||||
version: z.literal(5),
|
||||
mode: z.literal("smart"),
|
||||
|
||||
// ... existing V4 fields ...
|
||||
|
||||
// Mastery mode
|
||||
masteryMode: z.boolean().optional(),
|
||||
currentSkillId: z.string().optional(),
|
||||
|
||||
// NEW: Review customization
|
||||
reviewMixRatio: z.number().min(0).max(1).optional(), // 0-1, what fraction is review (default 0.25)
|
||||
selectedReviewSkills: z.array(z.string()).optional(), // Manual override of review skills
|
||||
|
||||
// NEW: Auto-advance preference
|
||||
autoAdvanceOnMastery: z.boolean().optional(), // Default true
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Design Decisions
|
||||
|
||||
### 1. Auto-Advance (Question 2 - Elaborated)
|
||||
**Decision**: Auto-advance after 5s with "Undo" and "Stay Here" options
|
||||
- Smooth guided experience
|
||||
- User retains control
|
||||
- Can generate multiple worksheets at mastered level if needed
|
||||
|
||||
### 2. Review Selection (Question 3)
|
||||
**Decision**: Recently mastered skills with recency windows
|
||||
- Recency window varies by skill level (30d → 7d as skills advance)
|
||||
- Prioritize skills from current skill's `recommendedReview` list
|
||||
- Max 3-4 review skills for variety
|
||||
|
||||
### 3. Dependency Tracking
|
||||
**Decision**: DAG-based prerequisite system like Civilization
|
||||
- Skills have explicit prerequisites (must master first)
|
||||
- Skills have recommended review list (related skills for practice)
|
||||
- UI shows locked/unlocked state based on prerequisites
|
||||
|
||||
### 4. Mix Ratio (Question 4)
|
||||
**Decision**: User-adjustable 0-100% range
|
||||
- Default: 75% current / 25% review
|
||||
- Can go to 100% current (0% review) for focused practice
|
||||
- Can go to 50% current / 50% review for heavy review mode
|
||||
- Can go to 0% current / 100% review for pure review (if needed)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready to implement? Suggested order:
|
||||
|
||||
1. **Phase 1**: Database + dependency system
|
||||
- Create worksheet_mastery table
|
||||
- Define SKILL_DEFINITIONS with prerequisites and recommendedReview
|
||||
- Implement selection algorithms
|
||||
|
||||
2. **Phase 2**: Basic mastery mode toggle
|
||||
- Add mode selector (Smart/Manual/Mastery)
|
||||
- Add MasteryModePanel with mix visualization
|
||||
- Wire up to problem generator
|
||||
|
||||
3. **Phase 3**: Auto-advance + review selection
|
||||
- Implement auto-advance toast with undo/stay
|
||||
- Implement recently-mastered selection algorithm
|
||||
- Add customize mix modal
|
||||
|
||||
4. **Phase 4**: Dependency visualization
|
||||
- Add "All Skills" modal with status indicators
|
||||
- Optional: Add tech tree visualization
|
||||
|
||||
Sound good?
|
||||
@@ -0,0 +1,503 @@
|
||||
# Mastery Mode - Executive Summary
|
||||
|
||||
## What Is It?
|
||||
|
||||
**Mastery Mode** is a third worksheet configuration mode (alongside Smart and Manual) that helps users generate pedagogically-appropriate practice worksheets based on skill progression.
|
||||
|
||||
Think of it as **Smart Difficulty with skill-based presets instead of difficulty presets**.
|
||||
|
||||
---
|
||||
|
||||
## Core Concept
|
||||
|
||||
### Current Smart Mode
|
||||
User picks a difficulty preset:
|
||||
- "Beginner" → No regrouping, full scaffolding
|
||||
- "Practice" → High regrouping, high scaffolding
|
||||
- "Expert" → High regrouping, no scaffolding
|
||||
|
||||
### New Mastery Mode
|
||||
User picks a skill to practice:
|
||||
- "Two-digit with ones regrouping" → Auto-configured for that specific skill
|
||||
- Worksheet automatically mixes 75% current skill + 25% review of mastered skills
|
||||
- User can customize the mix ratio and review selection
|
||||
|
||||
---
|
||||
|
||||
## User Experience Flow
|
||||
|
||||
### 1. Mode Selection
|
||||
|
||||
User sees three tabs at the top of the config panel:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ [ Smart ] [ Manual ] [Mastery] │ ← Click Mastery
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. Skill Selection
|
||||
|
||||
Interface shows current skill with navigation:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Current Skill: Two-digit with ones regrouping ✓ │
|
||||
│ │
|
||||
│ [← Previous] [Mark as Mastered] [Next →] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Actions:**
|
||||
- **← Previous / Next →**: Navigate through skill sequence
|
||||
- **Mark as Mastered**: Toggle mastery status (remembered for this user)
|
||||
- **✓ indicator**: Shows this skill is already mastered
|
||||
|
||||
### 3. Mix Visualization (Collapsed by Default)
|
||||
|
||||
Like the current difficulty preset dropdown, shows a summary:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Difficulty: Mastery - Two-digit ones regrouping ▼ │
|
||||
│ ────────────────────────────────────────────────── │
|
||||
│ 15 current skill, 5 review from 2 mastered skills │
|
||||
│ Recommended scaffolding │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Click to expand for full details:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Difficulty: Mastery - Two-digit ones regrouping ▲ │
|
||||
│ ────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 📊 Worksheet Mix (20 problems) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ 15 problems │ Two-digit + ones regroup │ 75% │
|
||||
│ │ (current) │ Example: 38 + 27 │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────┐ │
|
||||
│ │ 5 problems │ Review: Mastered skills │ 25% │
|
||||
│ │ (review) │ • 2: Single-digit regroup│ │
|
||||
│ │ │ • 3: Two-digit no regroup│ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⚙️ Scaffolding (recommended for this skill) │
|
||||
│ • Carry boxes when regrouping │
|
||||
│ • Answer boxes always │
|
||||
│ • Place value colors always │
|
||||
│ • Ten-frames when regrouping │
|
||||
│ │
|
||||
│ [View All Skills] [Customize Mix] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key observability features:**
|
||||
- **Current skill block** (blue): Shows count, percentage, example problem
|
||||
- **Review block** (green): Shows count, percentage, breakdown by skill
|
||||
- **Scaffolding summary**: What scaffolds are enabled for this skill
|
||||
- **Action buttons**: Access full skill list or customize the mix
|
||||
|
||||
### 4. Preview with Problem Attribution
|
||||
|
||||
Worksheet preview shows which problems are current vs review:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Problem 1 [Current] │
|
||||
│ 38 │
|
||||
│ + 27 │
|
||||
│ ---- │
|
||||
├─────────────────────────────────────┤
|
||||
│ Problem 2 [Review: 1d] │
|
||||
│ 7 │
|
||||
│ + 8 │
|
||||
│ ---- │
|
||||
├─────────────────────────────────────┤
|
||||
│ Problem 3 [Current] │
|
||||
│ 49 │
|
||||
│ + 15 │
|
||||
│ ---- │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Badges:**
|
||||
- Blue "Current" badge → Current skill problem
|
||||
- Green "Review: skill-name" → Review problem (shows which skill)
|
||||
|
||||
**Note:** Badges only appear in preview, not in final PDF (cleaner output).
|
||||
|
||||
---
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### View All Skills Modal
|
||||
|
||||
Click "View All Skills" to see complete progression:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ All Skills - Addition × │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ✓ Single-digit without regrouping │
|
||||
│ Mastered • [Practice This] [Unmark] │
|
||||
│ │
|
||||
│ ✓ Single-digit with regrouping │
|
||||
│ Mastered • [Practice This] [Unmark] │
|
||||
│ │
|
||||
│ ✓ Two-digit without regrouping │
|
||||
│ Mastered • [Practice This] [Unmark] │
|
||||
│ │
|
||||
│ ⭐ Two-digit with ones regrouping (Current) │
|
||||
│ 12 attempts • 78% accuracy │
|
||||
│ [Mark as Mastered] │
|
||||
│ │
|
||||
│ ○ Two-digit with mixed regrouping │
|
||||
│ Not started • [Practice This] │
|
||||
│ │
|
||||
│ ⊘ Two-digit with frequent regrouping │
|
||||
│ Locked • Requires: Two-digit mixed regrouping │
|
||||
│ │
|
||||
│ ... (11 skills total for addition) │
|
||||
│ │
|
||||
│ Progress: 3/11 skills mastered (27%) │
|
||||
│ │
|
||||
│ [Close] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Status indicators:**
|
||||
- ✓ = Mastered (green)
|
||||
- ⭐ = Current skill (blue, with stats)
|
||||
- ○ = Available (prerequisites met)
|
||||
- ⊘ = Locked (prerequisites not met)
|
||||
|
||||
**Actions per skill:**
|
||||
- **Practice This**: Switch to this skill
|
||||
- **Mark as Mastered / Unmark**: Toggle mastery
|
||||
- Shows prerequisite requirements for locked skills
|
||||
|
||||
### Customize Mix Modal
|
||||
|
||||
Click "Customize Mix" for fine control:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Customize Worksheet Mix × │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Mix Ratio │
|
||||
│ │
|
||||
│ Current Skill: 75% ███████████░░░ │
|
||||
│ Review: 25% ███░░░░░░░░░░ │
|
||||
│ │
|
||||
│ [━━━━━━●━━━━━━━━━━━] 75% │
|
||||
│ More review More current skill │
|
||||
│ │
|
||||
│ Review Skills │
|
||||
│ ☑ Single-digit with regrouping │
|
||||
│ ☑ Two-digit without regrouping │
|
||||
│ │
|
||||
│ [Reset to Default] [Cancel] [Apply] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Controls:**
|
||||
- **Slider**: Adjust current/review ratio (0-100% review)
|
||||
- **Checkboxes**: Select which mastered skills to include in review
|
||||
- **Reset**: Return to defaults (75% current, all recommended review skills)
|
||||
|
||||
---
|
||||
|
||||
## What Problems Get Generated?
|
||||
|
||||
### Example: Practicing "Two-digit with ones regrouping"
|
||||
|
||||
**Worksheet configuration** (20 problems):
|
||||
- **15 current skill problems** (75%):
|
||||
- Digit range: 2 digits
|
||||
- Regrouping: ~50% of problems need ones place regrouping
|
||||
- Examples: 38+27, 49+15, 56+38
|
||||
|
||||
- **5 review problems** (25%):
|
||||
- 2-3 problems from "Single-digit with regrouping" (7+8, 9+6)
|
||||
- 2-3 problems from "Two-digit without regrouping" (23+45, 31+28)
|
||||
|
||||
**Scaffolding** (auto-configured for this skill level):
|
||||
- Carry boxes: When regrouping
|
||||
- Answer boxes: Always
|
||||
- Place value colors: Always
|
||||
- Ten-frames: When regrouping
|
||||
|
||||
**Result**: Worksheet is pedagogically appropriate for a student working on this specific skill, with built-in review to prevent forgetting.
|
||||
|
||||
---
|
||||
|
||||
## Skill Progression
|
||||
|
||||
### 11 Addition Skills (Linear Progression)
|
||||
|
||||
```
|
||||
1. Single-digit without regrouping (3+5, 2+4)
|
||||
└→ 2. Single-digit with regrouping (7+8, 9+6)
|
||||
└→ 3. Two-digit without regrouping (23+45)
|
||||
└→ 4. Two-digit ones regrouping (38+27)
|
||||
└→ 5. Two-digit mixed regrouping (67+58)
|
||||
└→ 6. Two-digit full regrouping (88+99)
|
||||
└→ 7. Three-digit no regrouping (234+451)
|
||||
└→ 8. Three-digit simple regrouping (367+258)
|
||||
└→ 9. Three-digit full regrouping (888+999)
|
||||
└→ 10. Four-digit mastery (3847+2956)
|
||||
└→ 11. Five-digit mastery (38472+29563)
|
||||
```
|
||||
|
||||
### 10 Subtraction Skills (Mirror Structure)
|
||||
|
||||
Same progression pattern for subtraction (borrowing instead of carrying).
|
||||
|
||||
**Prerequisites**: Each skill requires mastering the previous one first.
|
||||
|
||||
**Review recommendations**: Each skill reviews 1-2 immediate prerequisites.
|
||||
|
||||
---
|
||||
|
||||
## Key UX Principles
|
||||
|
||||
### 1. **Transparency**
|
||||
User always sees:
|
||||
- What's in the mix (current skill vs review)
|
||||
- How many of each type of problem
|
||||
- Which skills are being reviewed
|
||||
- Why (based on dependency graph)
|
||||
|
||||
### 2. **Control**
|
||||
User can:
|
||||
- Navigate freely between skills
|
||||
- Manually mark skills as mastered/unmastered
|
||||
- Customize mix ratio (0-100% review)
|
||||
- Select specific review skills
|
||||
- Jump to any unlocked skill
|
||||
|
||||
### 3. **Simplicity**
|
||||
- No timers, no auto-advance
|
||||
- No complex time-based calculations
|
||||
- Just configuration presets organized by skill
|
||||
- Works exactly like Smart mode, but skill-focused
|
||||
|
||||
### 4. **Observability**
|
||||
- Collapsed summary (quick glance)
|
||||
- Expanded detail (full breakdown)
|
||||
- Problem attribution in preview
|
||||
- Progress tracking (X/Y skills mastered)
|
||||
|
||||
---
|
||||
|
||||
## What Makes This Better Than Smart Mode?
|
||||
|
||||
### Smart Mode
|
||||
- User picks difficulty level ("Beginner" to "Expert")
|
||||
- Adjusts regrouping probability and scaffolding globally
|
||||
- No concept of skill progression
|
||||
- No automatic review mixing
|
||||
- User manually adjusts digit range
|
||||
|
||||
### Mastery Mode
|
||||
- User picks pedagogical skill to practice
|
||||
- Problem configuration auto-tuned for that skill
|
||||
- Clear progression path (unlock skills in order)
|
||||
- Automatic review of prerequisite skills
|
||||
- Digit range determined by skill (2-digit skill = 2-digit problems)
|
||||
|
||||
**Result**: Teachers/parents get appropriate worksheets without understanding regrouping probabilities, scaffolding rules, or digit ranges. Just pick the skill and generate.
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Data Storage
|
||||
|
||||
**New table**: `worksheet_mastery`
|
||||
```sql
|
||||
CREATE TABLE worksheet_mastery (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
skill_id TEXT NOT NULL,
|
||||
is_mastered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
total_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
correct_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_accuracy REAL,
|
||||
mastered_at TIMESTAMP,
|
||||
last_practiced_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
**Tracks**:
|
||||
- Which skills user has mastered (boolean)
|
||||
- Attempt/accuracy stats (for future validation)
|
||||
- Timestamps (for UI display only, not logic)
|
||||
|
||||
### Problem Generation
|
||||
|
||||
```typescript
|
||||
// Phase 1: Determine current skill
|
||||
const currentSkill = SKILL_DEFINITIONS.find(s => s.id === formState.currentSkillId);
|
||||
|
||||
// Phase 2: Get review skills (from recommendedReview list, filtered by mastery)
|
||||
const reviewSkills = currentSkill.recommendedReview.filter(skillId =>
|
||||
masteryStates.get(skillId)?.isMastered === true
|
||||
);
|
||||
|
||||
// Phase 3: Calculate mix
|
||||
const total = formState.problemsPerPage;
|
||||
const mixRatio = formState.reviewMixRatio ?? 0.25; // Default 25% review
|
||||
const reviewCount = Math.floor(total * mixRatio);
|
||||
const currentCount = total - reviewCount;
|
||||
|
||||
// Phase 4: Generate problems
|
||||
const problems = [
|
||||
...generateProblemsForSkill(currentSkill, currentCount),
|
||||
...generateReviewProblems(reviewSkills, reviewCount)
|
||||
];
|
||||
|
||||
// Phase 5: Shuffle and return
|
||||
return shuffle(problems);
|
||||
```
|
||||
|
||||
### Config Schema (V5)
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: 5,
|
||||
mode: "smart",
|
||||
masteryMode: true, // NEW: Enable mastery mode
|
||||
currentSkillId: "td-ones-regroup", // NEW: Which skill to practice
|
||||
reviewMixRatio: 0.25, // NEW: What fraction is review (0-1)
|
||||
selectedReviewSkills: ["sd-simple-regroup", "td-no-regroup"], // NEW: Manual override
|
||||
// ... all existing smart mode fields
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (Backend)
|
||||
- Database migration for `worksheet_mastery` table
|
||||
- Define 21 `SKILL_DEFINITIONS` (11 addition, 10 subtraction)
|
||||
- API endpoints: GET/POST `/api/worksheets/mastery`
|
||||
- Problem generation with skill mix
|
||||
|
||||
### Phase 2: Basic UI
|
||||
- Mode selector (Smart/Manual/Mastery tabs)
|
||||
- Mastery mode panel with current skill display
|
||||
- Previous/Next navigation buttons
|
||||
- Collapsed/expanded mix visualization
|
||||
- "Mark as Mastered" toggle
|
||||
|
||||
### Phase 3: Modals
|
||||
- "View All Skills" modal with full progression
|
||||
- Progress tracking (X/Y skills mastered)
|
||||
- Click-to-select skill navigation
|
||||
|
||||
### Phase 4: Customization
|
||||
- "Customize Mix" modal with slider
|
||||
- Manual review skill selection
|
||||
- Custom mix ratio persistence
|
||||
|
||||
### Phase 5: Polish
|
||||
- Problem attribution badges in preview
|
||||
- Smooth transitions between skills
|
||||
- Responsive design (mobile/tablet)
|
||||
|
||||
---
|
||||
|
||||
## User Personas
|
||||
|
||||
### Persona 1: Parent Homeschooling 2nd Grader
|
||||
|
||||
**Goal**: Generate practice worksheets for child learning two-digit addition
|
||||
|
||||
**Flow**:
|
||||
1. Opens worksheet generator
|
||||
2. Clicks "Mastery" tab
|
||||
3. Sees child is on "Two-digit with ones regrouping"
|
||||
4. Clicks "Generate" → Gets appropriate worksheet
|
||||
5. After child completes it successfully, clicks "Mark as Mastered"
|
||||
6. Clicks "Next →" to move to "Two-digit mixed regrouping"
|
||||
7. Generates next worksheet
|
||||
|
||||
**Value**: No need to understand regrouping probabilities or scaffolding rules. Just follow the skill progression.
|
||||
|
||||
### Persona 2: Teacher Managing Classroom
|
||||
|
||||
**Goal**: Create differentiated worksheets for students at different levels
|
||||
|
||||
**Flow**:
|
||||
1. Opens worksheet generator
|
||||
2. Clicks "View All Skills"
|
||||
3. Sees student A is on skill #4, student B is on skill #7
|
||||
4. For student A: Selects skill #4, generates worksheet
|
||||
5. For student B: Selects skill #7, generates worksheet
|
||||
6. Both worksheets are pedagogically appropriate for their levels
|
||||
|
||||
**Value**: Quick access to any skill level, clear progression tracking, appropriate scaffolding per skill.
|
||||
|
||||
### Persona 3: Math Tutor
|
||||
|
||||
**Goal**: Create targeted practice with heavy review component
|
||||
|
||||
**Flow**:
|
||||
1. Opens worksheet generator
|
||||
2. Clicks "Mastery" tab
|
||||
3. Sees student is on "Three-digit simple regrouping"
|
||||
4. Clicks "Customize Mix"
|
||||
5. Adjusts slider to 50% review (more review than default)
|
||||
6. Checks which review skills to include
|
||||
7. Generates heavily-mixed worksheet
|
||||
|
||||
**Value**: Full control over mix ratio while maintaining skill-appropriate problem generation.
|
||||
|
||||
---
|
||||
|
||||
## Questions & Answers
|
||||
|
||||
### Q: What if I want pure practice (no review)?
|
||||
**A**: Adjust mix ratio slider to 100% current / 0% review.
|
||||
|
||||
### Q: What if I want pure review?
|
||||
**A**: Adjust mix ratio slider to 0% current / 100% review.
|
||||
|
||||
### Q: Can I practice a skill I haven't "unlocked" yet?
|
||||
**A**: Yes! Click "Practice This" on any skill. Prerequisites only affect the suggested progression, not what you can select.
|
||||
|
||||
### Q: Does marking a skill as mastered change anything automatically?
|
||||
**A**: No. It just updates the checkmark and enables that skill for review in future worksheets. You manually navigate to the next skill.
|
||||
|
||||
### Q: Can I go back to a mastered skill?
|
||||
**A**: Yes. Click "Practice This" on any skill, including mastered ones. Useful for generating extra practice or review.
|
||||
|
||||
### Q: What happens if I unmark a skill as mastered?
|
||||
**A**: It removes the checkmark and excludes it from review pools. You can re-mark it anytime.
|
||||
|
||||
### Q: Do the mastery states sync across devices?
|
||||
**A**: Yes, they're stored in the database per user account.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Mastery Mode = Smart Mode + Skill Progression + Automatic Review Mixing**
|
||||
|
||||
- Same underlying system (regrouping probabilities, scaffolding rules)
|
||||
- Organized around pedagogical skill sequence instead of difficulty levels
|
||||
- Automatically mixes current practice with review of prerequisites
|
||||
- User maintains full manual control
|
||||
- Transparent observability into what's being generated and why
|
||||
@@ -0,0 +1,219 @@
|
||||
# Mastery Progression Example: Single-Carry Technique
|
||||
|
||||
This shows the complete progression for learning the **single-carry** technique, demonstrating how scaffolding cycles as complexity increases.
|
||||
|
||||
## The Pattern: Scaffolding Fade Cycles
|
||||
|
||||
For each new complexity level:
|
||||
1. **WITH scaffolding** - Learn the pattern with visual support
|
||||
2. **WITHOUT scaffolding** - Internalize the concept
|
||||
3. **Next complexity WITH scaffolding** - Apply to harder problems with support again
|
||||
4. Repeat
|
||||
|
||||
## Complete Single-Carry Progression (12 objectives)
|
||||
|
||||
### Phase 1: Single-Digit Carrying (Entry Level)
|
||||
|
||||
#### Objective 1: Single-digit with ten-frames
|
||||
- **Technique**: single-carry
|
||||
- **Complexity**: sd-with-regroup (1-digit, 100% regrouping)
|
||||
- **Scaffolding**: FULL
|
||||
- **Config**:
|
||||
- digitRange: {min: 1, max: 1}
|
||||
- pAnyStart: 1.0, pAllStart: 0
|
||||
- tenFrames: 'whenRegrouping' ✓ (shows for all since all regroup)
|
||||
- carryBoxes: 'whenRegrouping' ✓
|
||||
- **Example problems**: 7+8, 9+6, 8+5 (all with ten-frames)
|
||||
- **Next**: Objective 2 (same complexity, less scaffolding)
|
||||
|
||||
#### Objective 2: Single-digit without ten-frames
|
||||
- **Technique**: single-carry
|
||||
- **Complexity**: sd-with-regroup (1-digit, 100% regrouping)
|
||||
- **Scaffolding**: PARTIAL (no ten-frames)
|
||||
- **Config**:
|
||||
- digitRange: {min: 1, max: 1}
|
||||
- pAnyStart: 1.0, pAllStart: 0
|
||||
- tenFrames: 'never' ✗ (removed)
|
||||
- carryBoxes: 'whenRegrouping' ✓ (still showing)
|
||||
- **Example problems**: 7+8, 9+6, 8+5 (no ten-frames)
|
||||
- **Next**: Objective 3 (higher complexity, reintroduce scaffolding)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Two-Digit Carrying (Ones Place Only)
|
||||
|
||||
#### Objective 3: Two-digit ones-carry with ten-frames
|
||||
- **Technique**: single-carry
|
||||
- **Complexity**: td-ones-regroup (2-digit, ones only)
|
||||
- **Scaffolding**: FULL (scaffolding RETURNS for new complexity!)
|
||||
- **Config**:
|
||||
- digitRange: {min: 2, max: 2}
|
||||
- pAnyStart: 1.0, pAllStart: 0
|
||||
- tenFrames: 'whenRegrouping' ✓ (BACK!)
|
||||
- carryBoxes: 'whenRegrouping' ✓
|
||||
- **Example problems**: 38+27 (with ten-frames), 49+15, 56+28
|
||||
- **Why ten-frames return?** New complexity = need visual support again
|
||||
- **Next**: Objective 4 (same complexity, fade scaffolding)
|
||||
|
||||
#### Objective 4: Two-digit ones-carry without ten-frames
|
||||
- **Technique**: single-carry
|
||||
- **Complexity**: td-ones-regroup (2-digit, ones only)
|
||||
- **Scaffolding**: PARTIAL
|
||||
- **Config**:
|
||||
- digitRange: {min: 2, max: 2}
|
||||
- pAnyStart: 1.0, pAllStart: 0
|
||||
- tenFrames: 'never' ✗
|
||||
- carryBoxes: 'whenRegrouping' ✓
|
||||
- **Example problems**: 38+27 (no ten-frames), 49+15, 56+28
|
||||
- **Next**: Objective 5 (higher complexity, reintroduce scaffolding)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Two-Digit Carrying (Mixed/All Places)
|
||||
|
||||
#### Objective 5: Two-digit mixed-carry with ten-frames
|
||||
- **Technique**: single-carry → multi-carry transition
|
||||
- **Complexity**: td-mixed-regroup (2-digit, 70% any, 30% all)
|
||||
- **Scaffolding**: FULL
|
||||
- **Config**:
|
||||
- digitRange: {min: 2, max: 2}
|
||||
- pAnyStart: 0.7, pAllStart: 0.3
|
||||
- tenFrames: 'whenRegrouping' ✓
|
||||
- carryBoxes: 'whenRegrouping' ✓
|
||||
- **Example problems**: Mix of 38+27 (ones), 57+68 (all), 23+45 (none)
|
||||
- **Next**: Objective 6
|
||||
|
||||
#### Objective 6: Two-digit mixed-carry without ten-frames
|
||||
- **Technique**: multi-carry (if pAllStart > 0)
|
||||
- **Complexity**: td-mixed-regroup
|
||||
- **Scaffolding**: PARTIAL
|
||||
- **Config**:
|
||||
- tenFrames: 'never' ✗
|
||||
- carryBoxes: 'whenRegrouping' ✓
|
||||
- **Next**: Objective 7 (higher complexity, reintroduce scaffolding)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Three-Digit Carrying (Ones Place Only)
|
||||
|
||||
#### Objective 7: Three-digit ones-carry with ten-frames
|
||||
- **Technique**: single-carry
|
||||
- **Complexity**: xd-ones-regroup (3-digit, ones only)
|
||||
- **Scaffolding**: FULL (scaffolding RETURNS again!)
|
||||
- **Config**:
|
||||
- digitRange: {min: 3, max: 3}
|
||||
- pAnyStart: 1.0, pAllStart: 0
|
||||
- tenFrames: 'whenRegrouping' ✓ (BACK AGAIN!)
|
||||
- carryBoxes: 'whenRegrouping' ✓
|
||||
- **Example problems**: 138+227, 549+315
|
||||
- **Why ten-frames again?** 3-digit is new complexity, needs support
|
||||
- **Next**: Objective 8
|
||||
|
||||
#### Objective 8: Three-digit ones-carry without ten-frames
|
||||
- **Technique**: single-carry
|
||||
- **Complexity**: xd-ones-regroup
|
||||
- **Scaffolding**: PARTIAL
|
||||
- **Config**:
|
||||
- tenFrames: 'never' ✗
|
||||
- carryBoxes: 'whenRegrouping' ✓
|
||||
- **Next**: Objective 9
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Three-Digit Multi-Carry
|
||||
|
||||
#### Objective 9: Three-digit multi-carry with ten-frames
|
||||
- **Technique**: multi-carry
|
||||
- **Complexity**: xd-multi-regroup (3-digit, 2+ places)
|
||||
- **Scaffolding**: FULL
|
||||
- **Config**:
|
||||
- digitRange: {min: 3, max: 3}
|
||||
- pAnyStart: 1.0, pAllStart: 0.5
|
||||
- tenFrames: 'whenRegrouping' ✓
|
||||
- carryBoxes: 'whenMultipleRegroups' (adjusted)
|
||||
- **Example problems**: 687+458, 879+264
|
||||
- **Next**: Objective 10
|
||||
|
||||
#### Objective 10: Three-digit multi-carry without ten-frames
|
||||
- **Technique**: multi-carry
|
||||
- **Complexity**: xd-multi-regroup
|
||||
- **Scaffolding**: MINIMAL
|
||||
- **Config**:
|
||||
- tenFrames: 'never' ✗
|
||||
- carryBoxes: 'whenMultipleRegroups'
|
||||
- **Next**: Objective 11
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Four-Digit (Advanced)
|
||||
|
||||
#### Objective 11: Four-digit with scaffolding
|
||||
- **Technique**: multi-carry
|
||||
- **Complexity**: xxd-mixed (4-digit)
|
||||
- **Scaffolding**: FULL (for new complexity)
|
||||
- **Config**:
|
||||
- digitRange: {min: 4, max: 4}
|
||||
- tenFrames: 'whenRegrouping' ✓ (might be useful)
|
||||
- carryBoxes: 'whenMultipleRegroups'
|
||||
- **Note**: At this level, ten-frames might be less useful (too many digits)
|
||||
- **Next**: Objective 12
|
||||
|
||||
#### Objective 12: Four-digit minimal scaffolding
|
||||
- **Technique**: multi-carry
|
||||
- **Complexity**: xxd-mixed
|
||||
- **Scaffolding**: MINIMAL
|
||||
- **Config**:
|
||||
- tenFrames: 'never' ✗
|
||||
- carryBoxes: 'whenMultipleRegroups'
|
||||
- **Next**: Five-digit, or MASTERED!
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
### 1. Scaffolding Cycles
|
||||
Ten-frames appear 6 times in this progression (objectives 1, 3, 5, 7, 9, 11), not just once!
|
||||
|
||||
### 2. Gradual Fade at Each Level
|
||||
Pattern: WITH → WITHOUT → (next complexity) WITH → WITHOUT
|
||||
|
||||
### 3. Two Dimensions of Progress
|
||||
- **Horizontal**: Fade scaffolding (full → partial → minimal)
|
||||
- **Vertical**: Increase complexity (1-digit → 2-digit → 3-digit → 4-digit)
|
||||
|
||||
### 4. Spiral Curriculum
|
||||
Students encounter the same technique multiple times with:
|
||||
- Increasing complexity
|
||||
- Decreasing scaffolding
|
||||
- Building mastery through repetition with variation
|
||||
|
||||
### 5. NOT a Linear Progression
|
||||
This is NOT: "learn with ten-frames, then never see them again"
|
||||
This IS: "cycle between scaffolded and unscaffolded practice as difficulty increases"
|
||||
|
||||
## Implementation in UI
|
||||
|
||||
Instead of showing 12 separate "skills", we could show:
|
||||
|
||||
```
|
||||
Technique: Single-place Carrying
|
||||
├─ 1-digit (with support) ✓
|
||||
├─ 1-digit (independent) ✓
|
||||
├─ 2-digit ones (with support) ✓
|
||||
├─ 2-digit ones (independent) ← currently practicing
|
||||
├─ 2-digit mixed (with support)
|
||||
├─ 2-digit mixed (independent)
|
||||
├─ 3-digit ones (with support)
|
||||
└─ ...
|
||||
```
|
||||
|
||||
Or even more compact:
|
||||
|
||||
```
|
||||
Single-place Carrying
|
||||
├─ 1-digit: ✓✓ (mastered)
|
||||
├─ 2-digit: ✓○ (scaffolding fading...)
|
||||
├─ 3-digit: ○○ (not started)
|
||||
```
|
||||
|
||||
Where ✓○ means "mastered with scaffolding, practicing without"
|
||||
@@ -0,0 +1,852 @@
|
||||
# Mastery Mode Redesign Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Redesign mastery mode to use a **1D slider that follows a curated path through 3D+ space**:
|
||||
- **Dimension 1**: Digit count (1-5 digits)
|
||||
- **Dimension 2**: Regrouping difficulty (0-100%)
|
||||
- **Dimension 3**: Scaffolding level (full → minimal)
|
||||
|
||||
The slider maps to discrete steps on a **progression path** that zig-zags through this space, reintroducing scaffolding (ten-frames) as complexity (digit count) increases.
|
||||
|
||||
**Key insight**: This is NOT a new mode - it's just **smart difficulty with a progression system**. All three modes (Smart/Manual/Mastery) can eventually merge into one unified UI.
|
||||
|
||||
## The Problem We're Solving
|
||||
|
||||
**Current issue**: "We show ten-frames for 2-digit regrouping, then never show them again even when we jump to 3-digit regrouping"
|
||||
|
||||
**Root cause**: Current "skills" are flat - no concept of scaffolding cycling as complexity increases.
|
||||
|
||||
**Solution**: Define a progression path where scaffolding cycles:
|
||||
- 2-digit WITH ten-frames → 2-digit WITHOUT ten-frames
|
||||
- 3-digit WITH ten-frames → 3-digit WITHOUT ten-frames (ten-frames return!)
|
||||
- 4-digit WITH ten-frames → 4-digit WITHOUT ten-frames (ten-frames return again!)
|
||||
|
||||
## Architecture: No New Worksheet Config Version
|
||||
|
||||
**IMPORTANT**: This does NOT create a new worksheet config version.
|
||||
|
||||
Current worksheet config (version 4) already has everything we need:
|
||||
- `digitRange: { min, max }`
|
||||
- `regroupingConfig: { pAnyStart, pAllStart }`
|
||||
- `displayRules: { tenFrames, carryBoxes, ... }`
|
||||
- `operator: 'addition' | 'subtraction' | 'mixed'`
|
||||
|
||||
The mastery progression is just **pre-defined combinations** of these existing fields.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Technique (What Skill?)
|
||||
|
||||
The actual mathematical skill being practiced:
|
||||
- **basic-addition**: No carrying
|
||||
- **single-carry**: Carrying in one place value
|
||||
- **multi-carry**: Carrying in multiple places
|
||||
- **basic-subtraction**: No borrowing
|
||||
- **single-borrow**: Borrowing from one place
|
||||
- **multi-borrow**: Borrowing across multiple places
|
||||
|
||||
**Just 6 techniques total.**
|
||||
|
||||
### 2. Complexity (How Hard?)
|
||||
|
||||
Problem characteristics:
|
||||
- Digit count (1, 2, 3, 4, 5 digits)
|
||||
- Regrouping frequency (0%, 50%, 100%)
|
||||
- Regrouping positions (ones only, tens only, multiple places)
|
||||
|
||||
**~13 complexity levels total.**
|
||||
|
||||
### 3. Scaffolding (How Much Support?)
|
||||
|
||||
Visual scaffolding provided:
|
||||
- **Full**: Ten-frames shown, carry boxes shown, place value colors
|
||||
- **Partial**: No ten-frames, still have carry boxes and colors
|
||||
- **Minimal**: Minimal visual aids
|
||||
|
||||
**2-3 scaffolding levels per (technique × complexity) pair.**
|
||||
|
||||
### 4. Progression Path = 1D Path Through 3D Space
|
||||
|
||||
Instead of letting users navigate freely through 3D space, we define a **curated learning path**:
|
||||
|
||||
```
|
||||
Path for single-carry technique:
|
||||
|
||||
Step 1: (1-digit, 100% regroup, full scaffold)
|
||||
Step 2: (1-digit, 100% regroup, minimal scaffold) ← scaffolding fades
|
||||
Step 3: (2-digit, 100% regroup, full scaffold) ← digit ↑, scaffold RETURNS
|
||||
Step 4: (2-digit, 100% regroup, minimal scaffold) ← scaffolding fades
|
||||
Step 5: (3-digit, 100% regroup, full scaffold) ← digit ↑, scaffold RETURNS
|
||||
Step 6: (3-digit, 100% regroup, minimal scaffold) ← scaffolding fades
|
||||
...
|
||||
```
|
||||
|
||||
This is the **zig-zag pattern**: increase complexity → reintroduce scaffolding → fade scaffolding → repeat.
|
||||
|
||||
## UI Design
|
||||
|
||||
### Primary Control: Difficulty Slider
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Difficulty Progression │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Easier ←―――――――●――――――――――――――――→ Harder │
|
||||
│ │
|
||||
│ Currently practicing: │
|
||||
│ • 2-digit problems │
|
||||
│ • Single-place carrying (ones place only) │
|
||||
│ • Full scaffolding (ten-frames shown) │
|
||||
│ │
|
||||
│ Progress: [●●●●○○○○○○○○] Step 4 of 12 │
|
||||
│ │
|
||||
│ Next milestone: │
|
||||
│ → Same problems, less scaffolding (no ten-frames) │
|
||||
│ │
|
||||
│ [Show Advanced Controls ▼] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Advanced Controls (Expandable)
|
||||
|
||||
When user clicks "Show Advanced Controls":
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ▼ Advanced Controls │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Technique: │
|
||||
│ ○ Basic Addition ● Single-carry ○ Multi-carry │
|
||||
│ │
|
||||
│ Digit Count: │
|
||||
│ [1] [2] [3] [4] [5] ← Button group │
|
||||
│ ○ ● ○ ○ ○ │
|
||||
│ │
|
||||
│ Regrouping Frequency: │
|
||||
│ None ←―――――●―――――――→ Always │
|
||||
│ (100% regrouping) │
|
||||
│ │
|
||||
│ Scaffolding Level: │
|
||||
│ ● Full (ten-frames, carry boxes, colors) │
|
||||
│ ○ Partial (carry boxes, colors only) │
|
||||
│ ○ Minimal (carry boxes only) │
|
||||
│ │
|
||||
│ ⚠ Manual changes will move you off the progression path│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Interaction Flow
|
||||
|
||||
**Scenario 1: User drags slider right (easier → harder)**
|
||||
1. Slider value changes: 33% → 42%
|
||||
2. Map to progression step: Step 4 → Step 5
|
||||
3. Step 5 config:
|
||||
- `digitRange: { min: 2, max: 2 }` (same)
|
||||
- `pAnyStart: 1.0` (same)
|
||||
- `tenFrames: 'whenRegrouping'` → `'never'` ← CHANGE
|
||||
4. Display updates: "2-digit problems, independent practice (no ten-frames)"
|
||||
5. Preview regenerates with new config
|
||||
|
||||
**Scenario 2: User drags slider further right**
|
||||
1. Slider value: 42% → 50%
|
||||
2. Map to step: Step 5 → Step 6
|
||||
3. Step 6 config:
|
||||
- `digitRange: { min: 2, max: 2 }` → `{ min: 3, max: 3 }` ← DIGIT INCREASE
|
||||
- `pAnyStart: 1.0` (same)
|
||||
- `tenFrames: 'never'` → `'whenRegrouping'` ← SCAFFOLDING RETURNS!
|
||||
4. Display updates: "3-digit problems with visual support (ten-frames)"
|
||||
5. Preview regenerates
|
||||
|
||||
**Scenario 3: User manually changes digit count**
|
||||
1. User expands "Advanced Controls"
|
||||
2. User clicks "4" in digit count buttons
|
||||
3. System searches progression path for nearest step with 4-digit
|
||||
4. Finds Step 9: (4-digit, 80% regroup, full scaffold)
|
||||
5. Slider jumps to 75% position
|
||||
6. Display updates: "Moved to Step 9"
|
||||
7. Warning shown: "You're now at 4-digit, but may want to practice 3-digit first"
|
||||
|
||||
## Data Structures
|
||||
|
||||
### 1. Progression Path Definition
|
||||
|
||||
```typescript
|
||||
// File: src/app/create/worksheets/addition/progressionPath.ts
|
||||
|
||||
import type { WorksheetFormState } from './types'
|
||||
|
||||
/**
|
||||
* A single step in the mastery progression path
|
||||
*/
|
||||
export interface ProgressionStep {
|
||||
// Unique ID for this step
|
||||
id: string
|
||||
|
||||
// Position in progression (0-based)
|
||||
stepNumber: number
|
||||
|
||||
// Which technique is being practiced
|
||||
technique: 'basic-addition' | 'single-carry' | 'multi-carry' | 'basic-subtraction' | 'single-borrow' | 'multi-borrow'
|
||||
|
||||
// Human-readable description
|
||||
name: string
|
||||
description: string
|
||||
|
||||
// Complete worksheet configuration for this step
|
||||
// This is worksheet config v4 format - no new version!
|
||||
config: Partial<WorksheetFormState>
|
||||
|
||||
// Mastery tracking
|
||||
masteryThreshold: number // e.g., 0.85 = 85% accuracy required
|
||||
minimumAttempts: number // e.g., 15 problems minimum
|
||||
|
||||
// What comes next?
|
||||
nextStepId: string | null
|
||||
previousStepId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete progression path for single-carry technique
|
||||
*/
|
||||
export const SINGLE_CARRY_PATH: ProgressionStep[] = [
|
||||
// Step 0: 1-digit with full scaffolding
|
||||
{
|
||||
id: 'single-carry-1d-full',
|
||||
stepNumber: 0,
|
||||
technique: 'single-carry',
|
||||
name: 'Single-digit carrying (with support)',
|
||||
description: 'Practice carrying with single-digit problems and ten-frames',
|
||||
config: {
|
||||
digitRange: { min: 1, max: 1 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // ← FULL SCAFFOLDING
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-1d-minimal',
|
||||
previousStepId: null,
|
||||
},
|
||||
|
||||
// Step 1: 1-digit with minimal scaffolding
|
||||
{
|
||||
id: 'single-carry-1d-minimal',
|
||||
stepNumber: 1,
|
||||
technique: 'single-carry',
|
||||
name: 'Single-digit carrying (independent)',
|
||||
description: 'Practice carrying without visual aids',
|
||||
config: {
|
||||
digitRange: { min: 1, max: 1 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // ← SCAFFOLDING FADED
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-2d-full',
|
||||
previousStepId: 'single-carry-1d-full',
|
||||
},
|
||||
|
||||
// Step 2: 2-digit with full scaffolding (SCAFFOLDING RETURNS!)
|
||||
{
|
||||
id: 'single-carry-2d-full',
|
||||
stepNumber: 2,
|
||||
technique: 'single-carry',
|
||||
name: 'Two-digit carrying (with support)',
|
||||
description: 'Apply carrying to two-digit problems with visual support',
|
||||
config: {
|
||||
digitRange: { min: 2, max: 2 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // ← SCAFFOLDING RETURNS for new complexity!
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-2d-minimal',
|
||||
previousStepId: 'single-carry-1d-minimal',
|
||||
},
|
||||
|
||||
// Step 3: 2-digit with minimal scaffolding
|
||||
{
|
||||
id: 'single-carry-2d-minimal',
|
||||
stepNumber: 3,
|
||||
technique: 'single-carry',
|
||||
name: 'Two-digit carrying (independent)',
|
||||
description: 'Practice two-digit carrying without visual aids',
|
||||
config: {
|
||||
digitRange: { min: 2, max: 2 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // ← SCAFFOLDING FADED
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-3d-full',
|
||||
previousStepId: 'single-carry-2d-full',
|
||||
},
|
||||
|
||||
// Step 4: 3-digit with full scaffolding (SCAFFOLDING RETURNS AGAIN!)
|
||||
{
|
||||
id: 'single-carry-3d-full',
|
||||
stepNumber: 4,
|
||||
technique: 'single-carry',
|
||||
name: 'Three-digit carrying (with support)',
|
||||
description: 'Apply carrying to three-digit problems with visual support',
|
||||
config: {
|
||||
digitRange: { min: 3, max: 3 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // ← SCAFFOLDING RETURNS for 3-digit!
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-3d-minimal',
|
||||
previousStepId: 'single-carry-2d-minimal',
|
||||
},
|
||||
|
||||
// Step 5: 3-digit with minimal scaffolding
|
||||
{
|
||||
id: 'single-carry-3d-minimal',
|
||||
stepNumber: 5,
|
||||
technique: 'single-carry',
|
||||
name: 'Three-digit carrying (independent)',
|
||||
description: 'Practice three-digit carrying without visual aids',
|
||||
config: {
|
||||
digitRange: { min: 3, max: 3 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // ← SCAFFOLDING FADED
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: null, // End of single-carry path
|
||||
previousStepId: 'single-carry-3d-full',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Map slider value (0-100) to progression step
|
||||
*/
|
||||
export function getStepFromSliderValue(
|
||||
sliderValue: number,
|
||||
path: ProgressionStep[]
|
||||
): ProgressionStep {
|
||||
const stepIndex = Math.round((sliderValue / 100) * (path.length - 1))
|
||||
return path[stepIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map progression step to slider value (0-100)
|
||||
*/
|
||||
export function getSliderValueFromStep(
|
||||
stepNumber: number,
|
||||
pathLength: number
|
||||
): number {
|
||||
return (stepNumber / (pathLength - 1)) * 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Find nearest step in path matching given config
|
||||
*/
|
||||
export function findNearestStep(
|
||||
config: Partial<WorksheetFormState>,
|
||||
path: ProgressionStep[]
|
||||
): ProgressionStep {
|
||||
// Score each step by how well it matches config
|
||||
let bestMatch = path[0]
|
||||
let bestScore = -Infinity
|
||||
|
||||
for (const step of path) {
|
||||
let score = 0
|
||||
|
||||
// Match digit range (most important)
|
||||
if (step.config.digitRange?.min === config.digitRange?.min &&
|
||||
step.config.digitRange?.max === config.digitRange?.max) {
|
||||
score += 100
|
||||
}
|
||||
|
||||
// Match regrouping config
|
||||
if (step.config.pAnyStart === config.pAnyStart) score += 50
|
||||
if (step.config.pAllStart === config.pAllStart) score += 50
|
||||
|
||||
// Match scaffolding (ten-frames)
|
||||
if (step.config.displayRules?.tenFrames === config.displayRules?.tenFrames) {
|
||||
score += 30
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = step
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Database Schema (No Changes Needed!)
|
||||
|
||||
**Current database** (already exists):
|
||||
```sql
|
||||
CREATE TABLE worksheet_mastery (
|
||||
user_id TEXT NOT NULL,
|
||||
skill_id TEXT NOT NULL, -- Can be step ID like 'single-carry-2d-full'
|
||||
is_mastered BOOLEAN NOT NULL DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
correct_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_practiced_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, skill_id)
|
||||
);
|
||||
```
|
||||
|
||||
We can reuse this table! Just use step IDs as skill IDs:
|
||||
- Old: `skill_id = 'td-ones-regroup'`
|
||||
- New: `skill_id = 'single-carry-2d-full'`
|
||||
|
||||
**Migration**: Map old skill IDs to new step IDs.
|
||||
|
||||
### 3. UI Component Structure
|
||||
|
||||
```typescript
|
||||
// File: src/app/create/worksheets/addition/components/config-panel/ProgressionModePanel.tsx
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SINGLE_CARRY_PATH, getStepFromSliderValue, getSliderValueFromStep } from '../../progressionPath'
|
||||
import type { WorksheetFormState } from '../../types'
|
||||
|
||||
interface ProgressionModePanelProps {
|
||||
formState: WorksheetFormState
|
||||
onChange: (updates: Partial<WorksheetFormState>) => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
export function ProgressionModePanel({ formState, onChange, isDark }: ProgressionModePanelProps) {
|
||||
// Current step (from formState.currentStepId or default to step 0)
|
||||
const currentStepId = formState.currentStepId ?? SINGLE_CARRY_PATH[0].id
|
||||
const currentStep = SINGLE_CARRY_PATH.find(s => s.id === currentStepId) ?? SINGLE_CARRY_PATH[0]
|
||||
|
||||
// Slider value derived from step
|
||||
const sliderValue = getSliderValueFromStep(currentStep.stepNumber, SINGLE_CARRY_PATH.length)
|
||||
|
||||
// Expanded/collapsed state for advanced controls
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
// Handle slider change
|
||||
const handleSliderChange = (newValue: number) => {
|
||||
const newStep = getStepFromSliderValue(newValue, SINGLE_CARRY_PATH)
|
||||
|
||||
// Apply step's config to form state
|
||||
onChange({
|
||||
currentStepId: newStep.id,
|
||||
...newStep.config,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle manual config changes
|
||||
const handleManualChange = (updates: Partial<WorksheetFormState>) => {
|
||||
onChange(updates)
|
||||
|
||||
// Find nearest step matching new config
|
||||
const nearestStep = findNearestStep({ ...formState, ...updates }, SINGLE_CARRY_PATH)
|
||||
|
||||
// Update step ID (might move off path)
|
||||
onChange({ currentStepId: nearestStep.id })
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="progression-mode-panel">
|
||||
{/* Slider */}
|
||||
<div>
|
||||
<label>Difficulty Progression</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sliderValue}
|
||||
onChange={(e) => handleSliderChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Current status */}
|
||||
<div>
|
||||
<h4>Currently practicing:</h4>
|
||||
<ul>
|
||||
<li>{currentStep.config.digitRange?.min}-digit problems</li>
|
||||
<li>{currentStep.name}</li>
|
||||
<li>
|
||||
{currentStep.config.displayRules?.tenFrames === 'whenRegrouping'
|
||||
? 'Full scaffolding (ten-frames shown)'
|
||||
: 'Independent practice (no ten-frames)'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div>
|
||||
Progress:
|
||||
{SINGLE_CARRY_PATH.map((step, i) => (
|
||||
<span key={step.id}>
|
||||
{i <= currentStep.stepNumber ? '●' : '○'}
|
||||
</span>
|
||||
))}
|
||||
Step {currentStep.stepNumber + 1} of {SINGLE_CARRY_PATH.length}
|
||||
</div>
|
||||
|
||||
{/* Next milestone */}
|
||||
{currentStep.nextStepId && (
|
||||
<div>
|
||||
Next milestone: {SINGLE_CARRY_PATH.find(s => s.id === currentStep.nextStepId)?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced controls */}
|
||||
<button onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
{showAdvanced ? 'Hide' : 'Show'} Advanced Controls
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div>
|
||||
{/* Digit count buttons */}
|
||||
<div>
|
||||
<label>Digit Count:</label>
|
||||
{[1, 2, 3, 4, 5].map(d => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => handleManualChange({ digitRange: { min: d, max: d } })}
|
||||
data-selected={formState.digitRange?.min === d}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Regrouping slider */}
|
||||
<div>
|
||||
<label>Regrouping Frequency:</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={(formState.pAnyStart ?? 0) * 100}
|
||||
onChange={(e) => handleManualChange({ pAnyStart: Number(e.target.value) / 100 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scaffolding radio buttons */}
|
||||
<div>
|
||||
<label>Scaffolding:</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={formState.displayRules?.tenFrames === 'whenRegrouping'}
|
||||
onChange={() => handleManualChange({
|
||||
displayRules: { ...formState.displayRules, tenFrames: 'whenRegrouping' }
|
||||
})}
|
||||
/>
|
||||
Full (ten-frames shown)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={formState.displayRules?.tenFrames === 'never'}
|
||||
onChange={() => handleManualChange({
|
||||
displayRules: { ...formState.displayRules, tenFrames: 'never' }
|
||||
})}
|
||||
/>
|
||||
Minimal (no ten-frames)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p>⚠ Manual changes may move you off the progression path</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Infrastructure (Week 1)
|
||||
|
||||
**Goal**: Set up progression path system without changing UI
|
||||
|
||||
1. ✅ Create `progressionPath.ts` with `ProgressionStep` type
|
||||
2. ✅ Define `SINGLE_CARRY_PATH` array (~6-8 steps)
|
||||
3. ✅ Create utility functions:
|
||||
- `getStepFromSliderValue()`
|
||||
- `getSliderValueFromStep()`
|
||||
- `findNearestStep()`
|
||||
4. ✅ Write tests for utility functions
|
||||
|
||||
**Deliverable**: Progression path data structure working, tested
|
||||
|
||||
### Phase 2: Update Mastery Mode UI (Week 1-2)
|
||||
|
||||
**Goal**: Replace current MasteryModePanel with slider-based UI
|
||||
|
||||
1. ✅ Create new `ProgressionModePanel` component
|
||||
2. ✅ Implement slider that maps to progression steps
|
||||
3. ✅ Show current step info (digit count, scaffolding, description)
|
||||
4. ✅ Show progress dots (mastered vs remaining)
|
||||
5. ✅ Add "Advanced Controls" expandable section
|
||||
6. ✅ Wire up to existing mastery database (reuse `worksheet_mastery` table)
|
||||
|
||||
**Deliverable**: New UI working in mastery mode
|
||||
|
||||
### Phase 3: Migration and Polish (Week 2)
|
||||
|
||||
**Goal**: Migrate old "skills" to new "steps"
|
||||
|
||||
1. ✅ Create migration mapping:
|
||||
```typescript
|
||||
const OLD_TO_NEW_SKILL_MAPPING = {
|
||||
'sd-simple-regroup': 'single-carry-1d-full',
|
||||
'td-ones-regroup': 'single-carry-2d-full',
|
||||
'xd-ones-regroup': 'single-carry-3d-full',
|
||||
// ... etc
|
||||
}
|
||||
```
|
||||
2. ✅ Write database migration script (optional, or do lazy migration)
|
||||
3. ✅ Update `AllSkillsModal` to show progression structure
|
||||
4. ✅ Polish styling, add animations
|
||||
5. ✅ User testing and feedback
|
||||
|
||||
**Deliverable**: Old mastery data works with new system
|
||||
|
||||
### Phase 4: Additional Paths (Week 3+)
|
||||
|
||||
**Goal**: Add more technique paths beyond single-carry
|
||||
|
||||
1. Create `BASIC_ADDITION_PATH` (no carrying)
|
||||
2. Create `MULTI_CARRY_PATH` (multiple place carrying)
|
||||
3. Create `SINGLE_BORROW_PATH` (subtraction)
|
||||
4. Add technique selector to UI
|
||||
5. Support multiple active paths simultaneously
|
||||
|
||||
**Deliverable**: Full progression system with all techniques
|
||||
|
||||
### Phase 5: Unify Modes (Future)
|
||||
|
||||
**Goal**: Merge Smart/Manual/Mastery into one unified UI
|
||||
|
||||
1. Recognize that all three modes set the same config fields
|
||||
2. Create unified config panel with three "entry points":
|
||||
- Guided (progression path)
|
||||
- Preset (difficulty profiles)
|
||||
- Advanced (manual settings)
|
||||
3. Remove mode selector entirely
|
||||
4. Gentle migration messaging for users
|
||||
|
||||
**Deliverable**: Single unified worksheet configuration UI
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Progression path utilities (`getStepFromSliderValue`, etc.)
|
||||
- Step mapping logic
|
||||
- Config merging
|
||||
|
||||
### Integration Tests
|
||||
- Slider changes update config correctly
|
||||
- Manual changes find nearest step
|
||||
- Database mastery tracking works
|
||||
|
||||
### User Testing
|
||||
- Can users understand the progression path?
|
||||
- Is the slider intuitive?
|
||||
- Do advanced controls make sense?
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Ten-frames return**: Verify ten-frames show for 3-digit after being hidden for 2-digit
|
||||
2. **User progression**: Students complete more steps in sequence
|
||||
3. **Mastery tracking**: Database shows gradual progress through steps
|
||||
4. **User feedback**: Positive feedback on clarity of progression
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **How many steps per path?**
|
||||
- Current: 6 steps for single-carry (1d→2d→3d, each with 2 scaffolding levels)
|
||||
- Could extend to 4d, 5d for advanced students
|
||||
|
||||
2. **Should we support multiple paths simultaneously?**
|
||||
- User practices single-carry AND single-borrow in parallel?
|
||||
- Or linear: finish single-carry before starting single-borrow?
|
||||
|
||||
3. **What about mixed operations?**
|
||||
- Separate path for mixed add/subtract?
|
||||
- Or just increase difficulty within single paths?
|
||||
|
||||
4. **Database migration strategy?**
|
||||
- Lazy migration (map on read)?
|
||||
- Batch migration script?
|
||||
- Support both old and new skill IDs?
|
||||
|
||||
## Mode Unification Strategy
|
||||
|
||||
### Current State: Three Separate Modes
|
||||
|
||||
Users currently choose between three mutually exclusive modes:
|
||||
- **Smart Difficulty**: Choose difficulty profile, get preset configs
|
||||
- **Manual Control**: Set all parameters manually
|
||||
- **Mastery Progression**: Follow skill-based progression
|
||||
|
||||
**Problem**: These feel like three different tools, but they all produce the same output (worksheet config v4).
|
||||
|
||||
### Proposed State: Unified Interface
|
||||
|
||||
**No mode selector.** One worksheet configuration UI with three "entry points":
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ How would you like to start? │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────┐│
|
||||
│ │ 🎯 Guided Path │ │ 📊 Quick Preset │ │ ⚙️ Custom││
|
||||
│ │ Follow learning │ │ Choose standard │ │ Set all ││
|
||||
│ │ progression │ │ difficulty │ │ settings ││
|
||||
│ └─────────────────┘ └─────────────────┘ └──────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### The Three Entry Points
|
||||
|
||||
**1. Guided Path** (was "Mastery Mode"):
|
||||
- Shows: Technique selector + difficulty slider
|
||||
- Hides: Full configuration controls (until "Show All Settings" clicked)
|
||||
- For: Teachers following curriculum, systematic learning
|
||||
|
||||
**2. Quick Preset** (was "Smart Difficulty"):
|
||||
- Shows: Preset difficulty buttons (Beginner/Intermediate/Advanced/Expert)
|
||||
- Hides: Full configuration controls (until "Show All Settings" clicked)
|
||||
- For: Quick worksheet generation at standard levels
|
||||
|
||||
**3. Custom** (was "Manual Control"):
|
||||
- Shows: Full configuration panel immediately
|
||||
- Hides: Nothing (power user mode)
|
||||
- For: Fine-grained control over all settings
|
||||
|
||||
### The Convergence: Progressive Disclosure
|
||||
|
||||
All three entry points lead to **the same underlying configuration**:
|
||||
- **Guided Path** and **Quick Preset** start collapsed, showing simplified controls
|
||||
- Clicking "Show All Settings" reveals the **full configuration panel**
|
||||
- **Custom** starts with full panel already visible
|
||||
|
||||
**They're the same interface**, just with different default visibility states.
|
||||
|
||||
### Migration Phases
|
||||
|
||||
**Phase 1**: Keep three modes (current), fix mastery bugs
|
||||
- No UI changes
|
||||
- Fix ten-frames cycling issue
|
||||
- Users see familiar interface
|
||||
|
||||
**Phase 2**: Add "Unified Mode" as 4th option
|
||||
- New users default to unified interface
|
||||
- Existing users can opt-in to try it
|
||||
- Gather feedback, iterate
|
||||
- Old modes remain available
|
||||
|
||||
**Phase 3**: Make unified the default
|
||||
- New users see unified by default
|
||||
- Existing users can still use old modes
|
||||
- Deprecation notice on classic modes
|
||||
|
||||
**Phase 4**: Remove old modes (breaking change)
|
||||
- Only unified interface remains
|
||||
- Auto-migrate saved configs
|
||||
- One-time user migration guide
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Simpler mental model**: One worksheet generator with different starting points
|
||||
2. **Progressive disclosure**: Beginners not overwhelmed, experts get full control
|
||||
3. **Smooth learning curve**: Start simple, gradually reveal complexity
|
||||
4. **Less code**: One component instead of three
|
||||
5. **No mode confusion**: No "which mode should I use?" decision paralysis
|
||||
6. **Same output**: All paths produce worksheet config v4
|
||||
|
||||
## Summary
|
||||
|
||||
**What we're building**: A slider-based UI that follows a curated learning path through (digit count × regrouping × scaffolding) space.
|
||||
|
||||
**Why it works**: The path zig-zags to reintroduce scaffolding as complexity increases, solving the ten-frames problem.
|
||||
|
||||
**What doesn't change**: Worksheet config format (still v4), database schema, problem generation logic.
|
||||
|
||||
**What does change**: Mastery mode UI becomes "Guided Path" entry point, eventually merges with other modes.
|
||||
|
||||
**End goal**: One unified worksheet configuration interface with three entry points (Guided/Preset/Custom) instead of three separate modes.
|
||||
@@ -0,0 +1,509 @@
|
||||
# Mastery System - Simplified for Worksheet Generation
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Mastery mode is a configuration helper, not a game.**
|
||||
|
||||
It helps users quickly configure appropriate problem sets based on what skills have been mastered. No timers, no auto-advance, no time-based logic.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Advance: REMOVED
|
||||
|
||||
**What we had**: Auto-advance toast with 5s timer
|
||||
|
||||
**What we need**: Just update the UI state, no automatic changes
|
||||
|
||||
**New behavior when user marks skill as mastered**:
|
||||
|
||||
```
|
||||
User clicks "Mark as Mastered" on skill
|
||||
↓
|
||||
Update mastery state in database
|
||||
↓
|
||||
Update UI (checkmark, status indicator)
|
||||
↓
|
||||
THAT'S IT. Stay on current skill.
|
||||
```
|
||||
|
||||
**To move to next skill**:
|
||||
- User explicitly clicks "Practice This" on a different skill
|
||||
- OR uses "Next Skill" / "Previous Skill" navigation buttons
|
||||
|
||||
**Rationale**: This is a worksheet generator. Users are configuring worksheets, not playing a progression game. They should have full control over what they're generating.
|
||||
|
||||
---
|
||||
|
||||
## Review Selection: Dependency Graph Only (No Time)
|
||||
|
||||
### Recency Windows: REMOVED
|
||||
|
||||
**What we had**: Time-based recency (30 days, 21 days, etc.)
|
||||
|
||||
**What we need**: Graph-based recency using prerequisite paths
|
||||
|
||||
### New Algorithm: "Recent in Dependency Graph"
|
||||
|
||||
**Definition**: A skill is "recently mastered" relative to current skill if it's on the **direct path** from root to current skill.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Get review skills based on dependency graph path
|
||||
* Returns skills that are direct prerequisites of current skill
|
||||
*/
|
||||
function getReviewSkills(
|
||||
currentSkill: SkillDefinition,
|
||||
masteryStates: Map<SkillId, MasteryState>
|
||||
): SkillId[] {
|
||||
// Simply return the current skill's recommendedReview list,
|
||||
// filtered to only include mastered skills
|
||||
return currentSkill.recommendedReview.filter(skillId => {
|
||||
const state = masteryStates.get(skillId);
|
||||
return state?.isMastered === true;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
|
||||
Current skill: `td-mixed-regroup`
|
||||
|
||||
```
|
||||
Dependency path:
|
||||
sd-no-regroup → sd-simple-regroup → td-no-regroup → td-ones-regroup → td-mixed-regroup
|
||||
^^^^^^^^^^^^^^^^
|
||||
(you are here)
|
||||
|
||||
recommendedReview: ["td-no-regroup", "td-ones-regroup"]
|
||||
```
|
||||
|
||||
**Review skills** (if mastered):
|
||||
- `td-no-regroup` ✓ (if mastered)
|
||||
- `td-ones-regroup` ✓ (if mastered)
|
||||
|
||||
That's it. No time calculations, no recency windows. Just "what's immediately behind you in the graph?"
|
||||
|
||||
---
|
||||
|
||||
## Simplified Skill Definitions
|
||||
|
||||
```typescript
|
||||
export interface SkillDefinition {
|
||||
id: SkillId;
|
||||
name: string;
|
||||
description: string;
|
||||
operator: "addition" | "subtraction";
|
||||
|
||||
// Problem generation
|
||||
digitRange: { min: number; max: number };
|
||||
regroupingConfig: { pAnyStart: number; pAllStart: number };
|
||||
recommendedScaffolding: DisplayRules;
|
||||
recommendedProblemCount: number;
|
||||
|
||||
// Mastery validation (future)
|
||||
masteryThreshold: number; // e.g., 0.85 = 85% accuracy
|
||||
minimumAttempts: number;
|
||||
|
||||
// Dependency graph
|
||||
prerequisites: SkillId[]; // Must master these first
|
||||
recommendedReview: SkillId[]; // Include these in review mix (1-2 recent prerequisites)
|
||||
}
|
||||
```
|
||||
|
||||
**Key change**: `recommendedReview` is **hand-curated**, not calculated. It's the 1-2 most relevant prerequisites for this skill.
|
||||
|
||||
### Example Definitions
|
||||
|
||||
```typescript
|
||||
export const SKILL_DEFINITIONS: SkillDefinition[] = [
|
||||
// Single-digit
|
||||
{
|
||||
id: "sd-no-regroup",
|
||||
name: "Single-digit without regrouping",
|
||||
description: "3+5, 2+4",
|
||||
operator: "addition",
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: { /* full scaffolding */ },
|
||||
recommendedProblemCount: 20,
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
prerequisites: [],
|
||||
recommendedReview: [], // First skill, no review
|
||||
},
|
||||
|
||||
{
|
||||
id: "sd-simple-regroup",
|
||||
name: "Single-digit with regrouping",
|
||||
description: "7+8, 9+6",
|
||||
operator: "addition",
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0 },
|
||||
recommendedScaffolding: { /* high scaffolding */ },
|
||||
recommendedProblemCount: 20,
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
prerequisites: ["sd-no-regroup"],
|
||||
recommendedReview: ["sd-no-regroup"], // Review the immediate prerequisite
|
||||
},
|
||||
|
||||
// Two-digit
|
||||
{
|
||||
id: "td-no-regroup",
|
||||
name: "Two-digit without regrouping",
|
||||
description: "23+45, 31+28",
|
||||
operator: "addition",
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: { /* medium scaffolding */ },
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ["sd-simple-regroup"],
|
||||
recommendedReview: ["sd-simple-regroup"], // Keep single-digit sharp
|
||||
},
|
||||
|
||||
{
|
||||
id: "td-ones-regroup",
|
||||
name: "Two-digit with ones place regrouping",
|
||||
description: "38+27, 49+15",
|
||||
operator: "addition",
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.5, pAllStart: 0 },
|
||||
recommendedScaffolding: { /* medium scaffolding */ },
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ["td-no-regroup"],
|
||||
recommendedReview: ["td-no-regroup"], // Focus on alignment + new regrouping
|
||||
},
|
||||
|
||||
{
|
||||
id: "td-mixed-regroup",
|
||||
name: "Two-digit with mixed regrouping",
|
||||
description: "67+58, 84+73",
|
||||
operator: "addition",
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.7, pAllStart: 0.2 },
|
||||
recommendedScaffolding: { /* reduced scaffolding */ },
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ["td-ones-regroup"],
|
||||
recommendedReview: ["td-no-regroup", "td-ones-regroup"], // Review both recent skills
|
||||
},
|
||||
|
||||
{
|
||||
id: "td-full-regroup",
|
||||
name: "Two-digit with frequent regrouping",
|
||||
description: "88+99, 76+67",
|
||||
operator: "addition",
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.9, pAllStart: 0.5 },
|
||||
recommendedScaffolding: { /* minimal scaffolding */ },
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ["td-mixed-regroup"],
|
||||
recommendedReview: ["td-ones-regroup", "td-mixed-regroup"], // Most recent two
|
||||
},
|
||||
|
||||
// Three-digit
|
||||
{
|
||||
id: "3d-no-regroup",
|
||||
name: "Three-digit without regrouping",
|
||||
description: "234+451, 123+456",
|
||||
operator: "addition",
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: { /* reduced scaffolding */ },
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ["td-full-regroup"],
|
||||
recommendedReview: ["td-mixed-regroup", "td-full-regroup"], // Keep 2-digit fresh
|
||||
},
|
||||
|
||||
{
|
||||
id: "3d-simple-regroup",
|
||||
name: "Three-digit with occasional regrouping",
|
||||
description: "367+258, 484+273",
|
||||
operator: "addition",
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0.5, pAllStart: 0.1 },
|
||||
recommendedScaffolding: { /* minimal scaffolding */ },
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ["3d-no-regroup"],
|
||||
recommendedReview: ["td-full-regroup", "3d-no-regroup"], // Bridge from 2d to 3d
|
||||
},
|
||||
|
||||
{
|
||||
id: "3d-full-regroup",
|
||||
name: "Three-digit with frequent regrouping",
|
||||
description: "888+999, 767+676",
|
||||
operator: "addition",
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0.9, pAllStart: 0.6 },
|
||||
recommendedScaffolding: { /* minimal scaffolding */ },
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ["3d-simple-regroup"],
|
||||
recommendedReview: ["3d-no-regroup", "3d-simple-regroup"], // Recent 3d only
|
||||
},
|
||||
|
||||
// Four/five-digit
|
||||
{
|
||||
id: "4d-mastery",
|
||||
name: "Four-digit mastery",
|
||||
description: "3847+2956",
|
||||
operator: "addition",
|
||||
digitRange: { min: 4, max: 4 },
|
||||
regroupingConfig: { pAnyStart: 0.8, pAllStart: 0.4 },
|
||||
recommendedScaffolding: { /* minimal scaffolding */ },
|
||||
recommendedProblemCount: 10,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 10,
|
||||
prerequisites: ["3d-full-regroup"],
|
||||
recommendedReview: ["3d-simple-regroup", "3d-full-regroup"], // Keep 3d sharp
|
||||
},
|
||||
|
||||
{
|
||||
id: "5d-mastery",
|
||||
name: "Five-digit mastery",
|
||||
description: "38472+29563",
|
||||
operator: "addition",
|
||||
digitRange: { min: 5, max: 5 },
|
||||
regroupingConfig: { pAnyStart: 0.85, pAllStart: 0.5 },
|
||||
recommendedScaffolding: { /* minimal scaffolding */ },
|
||||
recommendedProblemCount: 10,
|
||||
masteryThreshold: 0.75,
|
||||
minimumAttempts: 10,
|
||||
prerequisites: ["4d-mastery"],
|
||||
recommendedReview: ["3d-full-regroup", "4d-mastery"], // High-level review
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Distribution Algorithm (Simplified)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Distribute review problems across mastered skills from recommendedReview list
|
||||
*/
|
||||
function distributeReviewProblems(
|
||||
currentSkill: SkillDefinition,
|
||||
masteryStates: Map<SkillId, MasteryState>,
|
||||
totalReviewCount: number
|
||||
): Map<SkillId, number> {
|
||||
// Get mastered skills from recommendedReview list
|
||||
const reviewSkills = currentSkill.recommendedReview.filter(skillId => {
|
||||
const state = masteryStates.get(skillId);
|
||||
return state?.isMastered === true;
|
||||
});
|
||||
|
||||
const distribution = new Map<SkillId, number>();
|
||||
|
||||
if (reviewSkills.length === 0) {
|
||||
return distribution; // No review skills available
|
||||
}
|
||||
|
||||
if (reviewSkills.length === 1) {
|
||||
// Only one review skill: give it all review problems
|
||||
distribution.set(reviewSkills[0], totalReviewCount);
|
||||
return distribution;
|
||||
}
|
||||
|
||||
// Multiple review skills: distribute evenly
|
||||
const baseCount = Math.floor(totalReviewCount / reviewSkills.length);
|
||||
const remainder = totalReviewCount % reviewSkills.length;
|
||||
|
||||
reviewSkills.forEach((skillId, index) => {
|
||||
const count = baseCount + (index < remainder ? 1 : 0);
|
||||
distribution.set(skillId, count);
|
||||
});
|
||||
|
||||
return distribution;
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
|
||||
Current skill: `td-mixed-regroup`
|
||||
- `recommendedReview`: `["td-no-regroup", "td-ones-regroup"]`
|
||||
|
||||
Mastery state:
|
||||
- `td-no-regroup`: ✓ mastered
|
||||
- `td-ones-regroup`: ✓ mastered
|
||||
|
||||
Total review problems: 5
|
||||
|
||||
**Distribution**:
|
||||
- `td-no-regroup`: 2 problems
|
||||
- `td-ones-regroup`: 3 problems
|
||||
|
||||
Simple, deterministic, no time involved.
|
||||
|
||||
---
|
||||
|
||||
## UI Changes (Simplified)
|
||||
|
||||
### 1. Remove Auto-Advance Toast
|
||||
|
||||
**Old**: Toast with 5s timer
|
||||
|
||||
**New**: Simple confirmation message, no timer
|
||||
|
||||
```typescript
|
||||
// When user marks skill as mastered
|
||||
function handleMarkAsMastered(skillId: SkillId) {
|
||||
// Update database
|
||||
await updateMasteryState(skillId, true);
|
||||
|
||||
// Show simple confirmation
|
||||
toast.success("Skill marked as mastered!");
|
||||
|
||||
// Update UI state
|
||||
setMasteryStates(prev => new Map(prev).set(skillId, {
|
||||
...prev.get(skillId)!,
|
||||
isMastered: true,
|
||||
masteredAt: new Date()
|
||||
}));
|
||||
|
||||
// THAT'S IT. No auto-advance, no timers.
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Navigation Buttons for Moving Between Skills
|
||||
|
||||
**New UI element**: Simple previous/next buttons
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Current Skill: Two-digit ones regrouping ✓ │
|
||||
│ │
|
||||
│ [← Previous Skill] [Mark as Mastered] [Next Skill →] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```typescript
|
||||
function handleNextSkill() {
|
||||
const nextSkill = findNextSkill(currentSkillId, masteryStates);
|
||||
if (nextSkill) {
|
||||
setCurrentSkillId(nextSkill.id);
|
||||
// Regenerate preview with new skill
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviousSkill() {
|
||||
const prevSkill = findPreviousSkill(currentSkillId, masteryStates);
|
||||
if (prevSkill) {
|
||||
setCurrentSkillId(prevSkill.id);
|
||||
// Regenerate preview with new skill
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. "All Skills" Modal - Simplified
|
||||
|
||||
**Old**: Fancy tech tree with auto-selection
|
||||
|
||||
**New**: Simple list with click-to-select
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ All Skills - Addition × │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ ✓ Single-digit without regrouping │
|
||||
│ [Practice This] [Unmark] │
|
||||
│ │
|
||||
│ ✓ Single-digit with regrouping │
|
||||
│ [Practice This] [Unmark] │
|
||||
│ │
|
||||
│ ✓ Two-digit without regrouping │
|
||||
│ [Practice This] [Unmark] │
|
||||
│ │
|
||||
│ ⭐ Two-digit with ones regrouping (Current) │
|
||||
│ [Mark as Mastered] │
|
||||
│ │
|
||||
│ ○ Two-digit with mixed regrouping │
|
||||
│ [Practice This] │
|
||||
│ │
|
||||
│ ⊘ Two-digit with frequent regrouping │
|
||||
│ Locked - requires: Two-digit with mixed regrouping │
|
||||
│ │
|
||||
│ Progress: 3/11 skills mastered │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Actions**:
|
||||
- **Practice This**: Switch to this skill as current
|
||||
- **Mark as Mastered**: Toggle mastery state
|
||||
- **Unmark**: Remove mastery status
|
||||
|
||||
No timers, no auto-progression, just simple configuration.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Simplifications
|
||||
|
||||
### What We Removed
|
||||
|
||||
1. ❌ Auto-advance toast with 5s timer
|
||||
2. ❌ Time-based recency windows (30d, 21d, 7d)
|
||||
3. ❌ Time-based filtering of review skills
|
||||
4. ❌ Complex sorting by recency timestamp
|
||||
|
||||
### What We Kept
|
||||
|
||||
1. ✅ Mastery tracking (boolean per skill)
|
||||
2. ✅ Dependency graph (prerequisites)
|
||||
3. ✅ Hand-curated recommendedReview lists
|
||||
4. ✅ User-adjustable mix ratio (0-100% review)
|
||||
5. ✅ Manual skill selection
|
||||
6. ✅ Simple navigation (prev/next buttons)
|
||||
|
||||
### Core Philosophy
|
||||
|
||||
**Mastery mode = Smart configuration preset**
|
||||
|
||||
It's like the existing difficulty presets ("Beginner", "Practice", "Expert"), but:
|
||||
- Organized by skill progression
|
||||
- Remembers what you've mastered
|
||||
- Automatically mixes review problems
|
||||
- User has full manual control
|
||||
|
||||
**Not a game. Just a helpful configuration tool.**
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases (Updated)
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Create `worksheet_mastery` table
|
||||
2. Define `SKILL_DEFINITIONS` array with hand-curated `recommendedReview` lists
|
||||
3. Implement simple review selection (filter by mastery, distribute evenly)
|
||||
4. Add mastery GET/POST API endpoints
|
||||
|
||||
### Phase 2: Basic UI
|
||||
5. Add mode selector (Smart/Manual/Mastery)
|
||||
6. Add MasteryModePanel with mix visualization
|
||||
7. Add prev/next navigation buttons
|
||||
8. Add "Mark as Mastered" button
|
||||
|
||||
### Phase 3: Skills Modal
|
||||
9. Add "All Skills" modal with simple list
|
||||
10. Add click-to-select skill navigation
|
||||
11. Add manual mastery toggle per skill
|
||||
|
||||
### Phase 4: Customization
|
||||
12. Add "Customize Mix" modal with ratio slider
|
||||
13. Add manual review skill selection checkboxes
|
||||
14. Wire up custom mix to problem generator
|
||||
|
||||
Sound better?
|
||||
@@ -0,0 +1,573 @@
|
||||
# Mastery System Integration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add a **skill mastery tracking system** layered onto the existing smart difficulty mode. Students progress through discrete skills, with worksheets automatically mixing review problems with focused practice on skills approaching mastery.
|
||||
|
||||
**Core principle**: Mastery is **boolean per skill** - you either have mastered it or you haven't. Practice worksheets mix 70-80% current skill + 20-30% review of mastered skills.
|
||||
|
||||
---
|
||||
|
||||
## Skill Taxonomy (Based on Pedagogical Progression)
|
||||
|
||||
### Single-Digit Skills (1-digit operands)
|
||||
|
||||
These focus on **number parsing, basic counting, small regrouping, and low organizational demands**.
|
||||
|
||||
1. **`sd-no-regroup`** - Single-digit addition without regrouping (3+5, 2+4)
|
||||
- Challenges: Reading numbers, basic counting, understanding the operation
|
||||
- Mastery criteria: 90%+ accuracy on 20 problems
|
||||
|
||||
2. **`sd-simple-regroup`** - Single-digit addition with regrouping (7+8, 9+6)
|
||||
- Challenges: Understanding "making ten", small regrouping, place value introduction
|
||||
- Mastery criteria: 90%+ accuracy on 20 problems
|
||||
|
||||
### Two-Digit Skills (2-digit operands)
|
||||
|
||||
These require **alignment, high organization, pattern application, and place value understanding**.
|
||||
|
||||
3. **`td-no-regroup`** - Two-digit addition without regrouping (23+45, 31+28)
|
||||
- Challenges: Alignment, applying pattern to two places, understanding columns
|
||||
- Mastery criteria: 90%+ accuracy on 15 problems
|
||||
|
||||
4. **`td-ones-regroup`** - Two-digit addition with regrouping in ones place only (38+27, 49+15)
|
||||
- Challenges: Carrying from ones to tens, managing two-step process
|
||||
- Mastery criteria: 85%+ accuracy on 15 problems
|
||||
|
||||
5. **`td-mixed-regroup`** - Two-digit addition with occasional tens regrouping (67+58, 84+73)
|
||||
- Challenges: Handling regrouping in both positions (not always)
|
||||
- Mastery criteria: 85%+ accuracy on 15 problems
|
||||
|
||||
6. **`td-full-regroup`** - Two-digit addition with frequent regrouping (88+99, 76+67)
|
||||
- Challenges: High cognitive load, complex regrouping patterns
|
||||
- Mastery criteria: 80%+ accuracy on 15 problems
|
||||
|
||||
### Three-Digit Skills (3-digit operands)
|
||||
|
||||
These require **generalizing patterns** already learned in two-digit work.
|
||||
|
||||
7. **`3d-no-regroup`** - Three-digit addition without regrouping (234+451, 123+456)
|
||||
- Challenges: Applying known patterns to more columns, sustained attention
|
||||
- Mastery criteria: 85%+ accuracy on 12 problems
|
||||
|
||||
8. **`3d-simple-regroup`** - Three-digit with occasional regrouping (367+258, 484+273)
|
||||
- Challenges: Extending regrouping patterns to three places
|
||||
- Mastery criteria: 80%+ accuracy on 12 problems
|
||||
|
||||
9. **`3d-full-regroup`** - Three-digit with frequent complex regrouping (888+999, 767+676)
|
||||
- Challenges: Sustained focus, managing multiple carries
|
||||
- Mastery criteria: 80%+ accuracy on 12 problems
|
||||
|
||||
### Four+ Digit Skills (4-5 digit operands)
|
||||
|
||||
10. **`4d-mastery`** - Four-digit addition with varied regrouping (3847+2956)
|
||||
- Challenges: Sustained attention, confidence with larger numbers
|
||||
- Mastery criteria: 80%+ accuracy on 10 problems
|
||||
|
||||
11. **`5d-mastery`** - Five-digit addition (master level) (38472+29563)
|
||||
- Challenges: Full generalization, independence
|
||||
- Mastery criteria: 75%+ accuracy on 10 problems
|
||||
|
||||
### Subtraction Skills (Mirror progression)
|
||||
|
||||
12. **`sd-sub-no-borrow`** - Single-digit subtraction without borrowing (8-3, 9-4)
|
||||
13. **`sd-sub-borrow`** - Single-digit subtraction with borrowing (13-7, 15-8)
|
||||
14. **`td-sub-no-borrow`** - Two-digit subtraction without borrowing (68-43)
|
||||
15. **`td-sub-ones-borrow`** - Two-digit subtraction with borrowing in ones (52-27)
|
||||
16. **`td-sub-mixed-borrow`** - Two-digit subtraction with occasional tens borrowing
|
||||
17. **`td-sub-full-borrow`** - Two-digit subtraction with frequent borrowing (91-78)
|
||||
18. **`3d-sub-simple`** - Three-digit subtraction with occasional borrowing
|
||||
19. **`3d-sub-complex`** - Three-digit subtraction with frequent borrowing
|
||||
20. **`4d-sub-mastery`** - Four-digit subtraction mastery
|
||||
21. **`5d-sub-mastery`** - Five-digit subtraction mastery
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Database Schema (worksheet_mastery table)
|
||||
|
||||
```typescript
|
||||
export const worksheetMastery = sqliteTable("worksheet_mastery", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
skillId: text("skill_id").notNull(), // e.g., "td-ones-regroup"
|
||||
|
||||
// Mastery tracking
|
||||
isMastered: integer("is_mastered", { mode: "boolean" }).notNull().default(false),
|
||||
|
||||
// Evidence for mastery (for future validation)
|
||||
totalAttempts: integer("total_attempts").notNull().default(0),
|
||||
correctAttempts: integer("correct_attempts").notNull().default(0),
|
||||
lastAccuracy: real("last_accuracy"), // 0.0-1.0, most recent worksheet accuracy
|
||||
|
||||
// Timestamps
|
||||
firstAttemptAt: integer("first_attempt_at", { mode: "timestamp" }),
|
||||
masteredAt: integer("mastered_at", { mode: "timestamp" }),
|
||||
lastPracticedAt: integer("last_practiced_at", { mode: "timestamp" }).notNull(),
|
||||
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
// Composite index for fast user+skill lookups
|
||||
export const worksheetMasteryIndex = index("worksheet_mastery_user_skill_idx")
|
||||
.on(worksheetMastery.userId, worksheetMastery.skillId);
|
||||
```
|
||||
|
||||
### TypeScript Types
|
||||
|
||||
```typescript
|
||||
export type SkillId =
|
||||
// Single-digit addition
|
||||
| "sd-no-regroup"
|
||||
| "sd-simple-regroup"
|
||||
// Two-digit addition
|
||||
| "td-no-regroup"
|
||||
| "td-ones-regroup"
|
||||
| "td-mixed-regroup"
|
||||
| "td-full-regroup"
|
||||
// Three-digit addition
|
||||
| "3d-no-regroup"
|
||||
| "3d-simple-regroup"
|
||||
| "3d-full-regroup"
|
||||
// Four/five-digit addition
|
||||
| "4d-mastery"
|
||||
| "5d-mastery"
|
||||
// Single-digit subtraction
|
||||
| "sd-sub-no-borrow"
|
||||
| "sd-sub-borrow"
|
||||
// Two-digit subtraction
|
||||
| "td-sub-no-borrow"
|
||||
| "td-sub-ones-borrow"
|
||||
| "td-sub-mixed-borrow"
|
||||
| "td-sub-full-borrow"
|
||||
// Three-digit subtraction
|
||||
| "3d-sub-simple"
|
||||
| "3d-sub-complex"
|
||||
// Four/five-digit subtraction
|
||||
| "4d-sub-mastery"
|
||||
| "5d-sub-mastery";
|
||||
|
||||
export interface SkillDefinition {
|
||||
id: SkillId;
|
||||
name: string;
|
||||
description: string;
|
||||
operator: "addition" | "subtraction";
|
||||
|
||||
// Problem generation constraints
|
||||
digitRange: { min: number; max: number };
|
||||
regroupingConfig: {
|
||||
pAnyStart: number;
|
||||
pAllStart: number;
|
||||
};
|
||||
|
||||
// Pedagogical settings
|
||||
recommendedScaffolding: DisplayRules;
|
||||
recommendedProblemCount: number;
|
||||
|
||||
// Mastery validation (for future)
|
||||
masteryThreshold: number; // e.g., 0.85 = 85% accuracy
|
||||
minimumAttempts: number; // e.g., 15 problems to qualify
|
||||
|
||||
// Prerequisites (skills that should be mastered first)
|
||||
prerequisites: SkillId[];
|
||||
}
|
||||
|
||||
export interface MasteryState {
|
||||
userId: string;
|
||||
skillId: SkillId;
|
||||
isMastered: boolean;
|
||||
totalAttempts: number;
|
||||
correctAttempts: number;
|
||||
lastAccuracy: number | null;
|
||||
masteredAt: Date | null;
|
||||
lastPracticedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Smart Difficulty
|
||||
|
||||
### Current Smart Difficulty Flow
|
||||
|
||||
```
|
||||
User adjusts difficulty slider
|
||||
↓
|
||||
makeHarder/makeEasier functions
|
||||
↓
|
||||
Update pAnyStart, pAllStart, displayRules
|
||||
↓
|
||||
Generate problems based on probabilities
|
||||
```
|
||||
|
||||
### New Mastery-Enhanced Flow
|
||||
|
||||
```
|
||||
User selects "Mastery Mode" (new toggle in UI)
|
||||
↓
|
||||
System identifies current skill to practice
|
||||
(first non-mastered skill with prerequisites met)
|
||||
↓
|
||||
Load skill definition (digitRange, regrouping config, scaffolding)
|
||||
↓
|
||||
Generate mixed worksheet:
|
||||
- 70-80% current skill problems
|
||||
- 20-30% review from mastered skills (random selection)
|
||||
↓
|
||||
User completes worksheet (future: submit for grading)
|
||||
↓
|
||||
Update mastery state (future: based on accuracy)
|
||||
```
|
||||
|
||||
### Skill Selection Algorithm
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Find the next skill to practice based on mastery state
|
||||
*/
|
||||
function findNextSkill(
|
||||
masteryStates: Map<SkillId, MasteryState>,
|
||||
operator: "addition" | "subtraction"
|
||||
): SkillId | null {
|
||||
const skills = SKILL_DEFINITIONS.filter(s => s.operator === operator);
|
||||
|
||||
for (const skill of skills) {
|
||||
// Check if already mastered
|
||||
const state = masteryStates.get(skill.id);
|
||||
if (state?.isMastered) continue;
|
||||
|
||||
// Check if prerequisites are met
|
||||
const prereqsMet = skill.prerequisites.every(prereqId => {
|
||||
const prereqState = masteryStates.get(prereqId);
|
||||
return prereqState?.isMastered === true;
|
||||
});
|
||||
|
||||
if (!prereqsMet) continue;
|
||||
|
||||
// Found first non-mastered skill with prerequisites met
|
||||
return skill.id;
|
||||
}
|
||||
|
||||
return null; // All skills mastered!
|
||||
}
|
||||
```
|
||||
|
||||
### Problem Generation with Mastery Mix
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Generate problems for mastery practice worksheet
|
||||
*/
|
||||
function generateMasteryWorksheet(
|
||||
currentSkill: SkillDefinition,
|
||||
masteredSkills: SkillDefinition[],
|
||||
total: number,
|
||||
rng: SeededRandom
|
||||
): WorksheetProblem[] {
|
||||
const currentSkillCount = Math.floor(total * 0.75); // 75% current skill
|
||||
const reviewCount = total - currentSkillCount; // 25% review
|
||||
|
||||
const problems: WorksheetProblem[] = [];
|
||||
|
||||
// Generate current skill problems
|
||||
for (let i = 0; i < currentSkillCount; i++) {
|
||||
problems.push(generateProblemForSkill(currentSkill, rng));
|
||||
}
|
||||
|
||||
// Generate review problems from mastered skills
|
||||
for (let i = 0; i < reviewCount; i++) {
|
||||
if (masteredSkills.length === 0) {
|
||||
// No mastered skills yet, generate more current skill problems
|
||||
problems.push(generateProblemForSkill(currentSkill, rng));
|
||||
} else {
|
||||
// Pick random mastered skill
|
||||
const reviewSkill = masteredSkills[Math.floor(rng.random() * masteredSkills.length)];
|
||||
problems.push(generateProblemForSkill(reviewSkill, rng));
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle to mix review and practice
|
||||
return shuffleArray(problems, rng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single problem matching skill definition
|
||||
*/
|
||||
function generateProblemForSkill(
|
||||
skill: SkillDefinition,
|
||||
rng: SeededRandom
|
||||
): WorksheetProblem {
|
||||
// Use skill's digitRange and regrouping config
|
||||
return generateProblem(
|
||||
skill.digitRange,
|
||||
skill.regroupingConfig.pAnyStart,
|
||||
skill.regroupingConfig.pAllStart,
|
||||
rng
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Changes
|
||||
|
||||
### Config Panel - New Mastery Mode Toggle
|
||||
|
||||
Add to `SmartModeControls.tsx`:
|
||||
|
||||
```typescript
|
||||
<div data-section="mastery-mode-toggle">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formState.masteryMode ?? false}
|
||||
onChange={(e) => onChange({ masteryMode: e.target.checked })}
|
||||
/>
|
||||
<span>Mastery Mode</span>
|
||||
</label>
|
||||
<p className={css({ fontSize: "0.875rem", color: "gray.600" })}>
|
||||
Practice one skill at a time with automatic review of mastered skills
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formState.masteryMode && (
|
||||
<div data-section="current-skill-indicator">
|
||||
<p>Current skill: <strong>{currentSkill.name}</strong></p>
|
||||
<p>{currentSkill.description}</p>
|
||||
<div>
|
||||
Progress: {masteredCount}/{totalSkills} skills mastered
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Skill Progress Indicator
|
||||
|
||||
New component to show skill progression:
|
||||
|
||||
```typescript
|
||||
<div data-component="skill-progress">
|
||||
{SKILL_DEFINITIONS
|
||||
.filter(s => s.operator === formState.operator)
|
||||
.map(skill => {
|
||||
const state = masteryStates.get(skill.id);
|
||||
const isMastered = state?.isMastered ?? false;
|
||||
const isCurrent = skill.id === currentSkill.id;
|
||||
const prereqsMet = checkPrereqs(skill, masteryStates);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.5rem",
|
||||
backgroundColor: isCurrent ? "blue.50" : "transparent",
|
||||
borderLeft: isCurrent ? "3px solid blue.500" : "none"
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{isMastered ? "✓" : prereqsMet ? "○" : "⊘"}
|
||||
</div>
|
||||
<div>
|
||||
<div>{skill.name}</div>
|
||||
{state && !isMastered && (
|
||||
<div className={css({ fontSize: "0.75rem", color: "gray.600" })}>
|
||||
{state.totalAttempts} attempts, {Math.round((state.lastAccuracy ?? 0) * 100)}% accuracy
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Changes
|
||||
|
||||
### New Endpoint: `/api/worksheets/mastery`
|
||||
|
||||
```typescript
|
||||
// GET /api/worksheets/mastery
|
||||
// Returns user's mastery state for all skills
|
||||
export async function GET(req: Request) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const masteryRecords = await db
|
||||
.select()
|
||||
.from(worksheetMastery)
|
||||
.where(eq(worksheetMastery.userId, session.user.id));
|
||||
|
||||
return NextResponse.json({ mastery: masteryRecords });
|
||||
}
|
||||
|
||||
// POST /api/worksheets/mastery
|
||||
// Update mastery state for a skill (manual toggle or future grading)
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { skillId, isMastered } = await req.json();
|
||||
|
||||
// Validate skillId
|
||||
if (!SKILL_IDS.includes(skillId)) {
|
||||
return NextResponse.json({ error: "Invalid skill ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upsert mastery record
|
||||
await db
|
||||
.insert(worksheetMastery)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: session.user.id,
|
||||
skillId,
|
||||
isMastered,
|
||||
masteredAt: isMastered ? new Date() : null,
|
||||
lastPracticedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [worksheetMastery.userId, worksheetMastery.skillId],
|
||||
set: {
|
||||
isMastered,
|
||||
masteredAt: isMastered ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config Schema Changes (V5)
|
||||
|
||||
Add `masteryMode` and `currentSkillId` to worksheet config:
|
||||
|
||||
```typescript
|
||||
export const additionConfigV5SmartSchema = z.object({
|
||||
version: z.literal(5),
|
||||
mode: z.literal("smart"),
|
||||
|
||||
// Existing V4 fields
|
||||
problemsPerPage: z.number().int().min(1).max(100),
|
||||
cols: z.number().int().min(1).max(10),
|
||||
pages: z.number().int().min(1).max(10),
|
||||
orientation: z.enum(["portrait", "landscape"]),
|
||||
name: z.string().max(100),
|
||||
digitRange: z.object({
|
||||
min: z.number().int().min(1).max(5),
|
||||
max: z.number().int().min(1).max(5),
|
||||
}),
|
||||
operator: z.enum(["addition", "subtraction", "mixed"]),
|
||||
fontSize: z.number().int().min(8).max(32),
|
||||
|
||||
// Smart mode specific
|
||||
displayRules: displayRulesSchema,
|
||||
difficultyProfile: z.string().optional(),
|
||||
|
||||
// NEW: Mastery mode fields
|
||||
masteryMode: z.boolean().optional(), // Enable mastery-based practice
|
||||
currentSkillId: z.string().optional(), // Which skill to practice (auto-selected if not set)
|
||||
});
|
||||
|
||||
// Migration V4 → V5
|
||||
function migrateAdditionV4toV5(v4: AdditionConfigV4): AdditionConfigV5 {
|
||||
return {
|
||||
...v4,
|
||||
version: 5,
|
||||
masteryMode: false, // Default: off
|
||||
currentSkillId: undefined, // Auto-select based on mastery state
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (No UI changes yet)
|
||||
1. Create database migration for `worksheet_mastery` table
|
||||
2. Define `SKILL_DEFINITIONS` array with all 21 skills
|
||||
3. Implement `findNextSkill()` algorithm
|
||||
4. Implement `generateMasteryWorksheet()` function
|
||||
5. Add mastery GET/POST API endpoints
|
||||
|
||||
### Phase 2: Basic UI Integration
|
||||
6. Add mastery mode toggle to Smart Mode Controls
|
||||
7. Add current skill indicator
|
||||
8. Wire up mastery mode to problem generator
|
||||
9. Test with manual mastery toggles
|
||||
|
||||
### Phase 3: Progress Tracking
|
||||
10. Add skill progress visualization
|
||||
11. Show mastery status for each skill
|
||||
12. Add manual mastery toggle per skill (teacher/parent override)
|
||||
|
||||
### Phase 4: Future - Automatic Validation
|
||||
13. Add worksheet submission endpoint
|
||||
14. Implement grading logic
|
||||
15. Automatic mastery updates based on accuracy
|
||||
16. Student feedback on mastery progress
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Should mastery be per-user or per-student-profile?**
|
||||
- Current: per-user (one mastery state per user account)
|
||||
- Alternative: per-student (parent account can track multiple children)
|
||||
|
||||
2. **Should we allow manual mastery override?**
|
||||
- YES for Phase 3 (teacher/parent can mark skill as mastered)
|
||||
- Future: Require both manual + validation evidence
|
||||
|
||||
3. **What's the review mix percentage?**
|
||||
- Proposed: 75% current skill, 25% review
|
||||
- Should this be configurable?
|
||||
|
||||
4. **How do we handle "mixed" operator worksheets?**
|
||||
- Option A: Disable mastery mode for "mixed" (only works for pure addition or subtraction)
|
||||
- Option B: Track addition and subtraction mastery separately, mix review from both
|
||||
|
||||
5. **Should digit range be locked when in mastery mode?**
|
||||
- YES - mastery mode overrides digit range (determined by current skill)
|
||||
- User can still use manual difficulty mode if they want full control
|
||||
|
||||
---
|
||||
|
||||
## Pedagogical Notes
|
||||
|
||||
### Why This Progression Works
|
||||
|
||||
1. **Single-digit first** - Builds number sense and basic operation understanding without organizational complexity
|
||||
2. **Two-digit is the critical phase** - Introduces alignment, place value, and pattern application (hardest cognitive leap)
|
||||
3. **Three-digit is pattern generalization** - If you master two-digit, three-digit is mostly more of the same
|
||||
4. **Four/five-digit is confidence building** - Proves they can handle "big scary numbers"
|
||||
|
||||
### Why Mastery Mix Matters
|
||||
|
||||
- **Pure practice is boring** - Drilling only one skill leads to disengagement
|
||||
- **Review prevents forgetting** - Spaced repetition of mastered skills
|
||||
- **Mixed practice builds fluency** - Switching between skills improves transfer
|
||||
- **70/30 ratio is pedagogically sound** - Majority practice on current skill, enough review to maintain mastery
|
||||
|
||||
### Scaffolding Considerations
|
||||
|
||||
- Early skills (sd-*, td-no-regroup) should have HIGH scaffolding
|
||||
- Middle skills (td-ones-regroup, td-mixed-regroup) should have MEDIUM scaffolding
|
||||
- Advanced skills (3d-*, 4d-*, 5d-*) should have LOW scaffolding
|
||||
- Mastery mode should respect recommended scaffolding for each skill
|
||||
863
apps/web/src/app/create/worksheets/addition/MASTERY_UI_PLAN.md
Normal file
@@ -0,0 +1,863 @@
|
||||
# Mastery Mode UI Plan
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Transparency**: Always show what's in the mix and why
|
||||
2. **Progressive disclosure**: Simple by default, detailed on demand
|
||||
3. **Consistent patterns**: Reuse existing UI patterns (like difficulty preset dropdown)
|
||||
4. **No surprises**: Clear indication when mastery mode changes behavior
|
||||
|
||||
---
|
||||
|
||||
## UI Components
|
||||
|
||||
### 1. Mode Selector (Top of Config Panel)
|
||||
|
||||
**Current**: Just shows "Smart" vs "Manual" mode tabs
|
||||
|
||||
**New**: Add third option for "Mastery"
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ┌───────┬────────┬─────────┐ │
|
||||
│ │ Smart │ Manual │ Mastery │ │
|
||||
│ └───────┴────────┴─────────┘ │
|
||||
│ ^^^^^^^^^ NEW │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component**: `ModeSelector.tsx`
|
||||
|
||||
```typescript
|
||||
<div data-component="mode-selector">
|
||||
<button
|
||||
data-mode="smart"
|
||||
className={css({ /* active styles if selected */ })}
|
||||
onClick={() => onChange({ mode: "smart", masteryMode: false })}
|
||||
>
|
||||
Smart
|
||||
</button>
|
||||
<button
|
||||
data-mode="manual"
|
||||
className={css({ /* active styles if selected */ })}
|
||||
onClick={() => onChange({ mode: "manual", masteryMode: false })}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
<button
|
||||
data-mode="mastery"
|
||||
className={css({ /* active styles if selected */ })}
|
||||
onClick={() => onChange({ mode: "smart", masteryMode: true })}
|
||||
>
|
||||
Mastery
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Mastery Mode Panel (Replaces Smart/Manual Controls)
|
||||
|
||||
When mastery mode is active, replace the difficulty slider with mastery-specific controls.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Current Skill: Two-digit with ones regrouping ✓ │
|
||||
│ ─────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 📊 Worksheet Mix (20 problems) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ 15 problems │ Two-digit + ones regrouping │ 75% │
|
||||
│ │ (current) │ Example: 38 + 27 │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ 5 problems │ Review: Mixed mastered skills │ 25% │
|
||||
│ │ (review) │ • 2 problems: Single-digit │ │
|
||||
│ │ │ • 3 problems: Two-digit simple│ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⚙️ Scaffolding (recommended for this skill) │
|
||||
│ • Carry boxes when regrouping │
|
||||
│ • Answer boxes always visible │
|
||||
│ • Place value colors always visible │
|
||||
│ • Ten-frames when regrouping │
|
||||
│ │
|
||||
│ [ View All Skills ] [ Customize Mix ] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component**: `MasteryModePanel.tsx`
|
||||
|
||||
```typescript
|
||||
interface MasteryModePanelProps {
|
||||
currentSkill: SkillDefinition;
|
||||
masteryStates: Map<SkillId, MasteryState>;
|
||||
totalProblems: number;
|
||||
onCustomize: () => void;
|
||||
onViewAllSkills: () => void;
|
||||
}
|
||||
|
||||
export function MasteryModePanel({
|
||||
currentSkill,
|
||||
masteryStates,
|
||||
totalProblems,
|
||||
onCustomize,
|
||||
onViewAllSkills,
|
||||
}: MasteryModePanelProps) {
|
||||
const masteredSkills = getMasteredSkills(masteryStates, currentSkill.operator);
|
||||
const currentCount = Math.floor(totalProblems * 0.75);
|
||||
const reviewCount = totalProblems - currentCount;
|
||||
|
||||
// Calculate review breakdown (how review problems are distributed)
|
||||
const reviewBreakdown = calculateReviewBreakdown(masteredSkills, reviewCount);
|
||||
|
||||
return (
|
||||
<div data-component="mastery-mode-panel">
|
||||
{/* Current skill header */}
|
||||
<div data-section="current-skill-header">
|
||||
<h3>{currentSkill.name}</h3>
|
||||
<p className={css({ fontSize: "0.875rem", color: "gray.600" })}>
|
||||
{currentSkill.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Worksheet mix visualization */}
|
||||
<div data-section="worksheet-mix">
|
||||
<h4>📊 Worksheet Mix ({totalProblems} problems)</h4>
|
||||
|
||||
{/* Current skill block */}
|
||||
<div data-element="current-skill-block" className={css({
|
||||
border: "2px solid blue.500",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem",
|
||||
marginBottom: "0.5rem"
|
||||
})}>
|
||||
<div className={css({ display: "flex", justifyContent: "space-between" })}>
|
||||
<div>
|
||||
<strong>{currentCount} problems</strong>
|
||||
<span className={css({ color: "gray.600", marginLeft: "0.5rem" })}>
|
||||
(current)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ fontWeight: "bold", color: "blue.600" })}>
|
||||
{Math.round((currentCount / totalProblems) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className={css({ marginTop: "0.5rem" })}>
|
||||
<div>{currentSkill.name}</div>
|
||||
<div className={css({ fontSize: "0.875rem", color: "gray.600" })}>
|
||||
Example: {generateExampleProblem(currentSkill)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review block */}
|
||||
<div data-element="review-block" className={css({
|
||||
border: "2px solid green.500",
|
||||
borderRadius: "8px",
|
||||
padding: "1rem"
|
||||
})}>
|
||||
<div className={css({ display: "flex", justifyContent: "space-between" })}>
|
||||
<div>
|
||||
<strong>{reviewCount} problems</strong>
|
||||
<span className={css({ color: "gray.600", marginLeft: "0.5rem" })}>
|
||||
(review)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ fontWeight: "bold", color: "green.600" })}>
|
||||
{Math.round((reviewCount / totalProblems) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{masteredSkills.length === 0 ? (
|
||||
<div className={css({ marginTop: "0.5rem", fontSize: "0.875rem", color: "gray.600" })}>
|
||||
No mastered skills yet. All problems will focus on current skill.
|
||||
</div>
|
||||
) : (
|
||||
<div className={css({ marginTop: "0.5rem" })}>
|
||||
<div className={css({ fontWeight: "500" })}>
|
||||
Review: Mixed mastered skills
|
||||
</div>
|
||||
<ul className={css({ fontSize: "0.875rem", color: "gray.600", marginTop: "0.25rem" })}>
|
||||
{reviewBreakdown.map(({ skill, count }) => (
|
||||
<li key={skill.id}>
|
||||
• {count} problem{count > 1 ? 's' : ''}: {skill.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scaffolding summary */}
|
||||
<div data-section="scaffolding-summary">
|
||||
<h4>⚙️ Scaffolding (recommended for this skill)</h4>
|
||||
<div className={css({ fontSize: "0.875rem" })}>
|
||||
{renderScaffoldingSummary(currentSkill.recommendedScaffolding, currentSkill.operator)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div data-section="mastery-actions" className={css({
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem"
|
||||
})}>
|
||||
<button onClick={onViewAllSkills} className={css({ /* button styles */ })}>
|
||||
View All Skills
|
||||
</button>
|
||||
<button onClick={onCustomize} className={css({ /* button styles */ })}>
|
||||
Customize Mix
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Collapsed Difficulty Summary (Like Current Presets)
|
||||
|
||||
When mastery mode is active, show a collapsed summary similar to the existing preset dropdown.
|
||||
|
||||
**Pattern**: Reuse the existing `DifficultyPresetDropdown` pattern but with mastery-specific content.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Difficulty: Mastery - Two-digit ones regrouping ▼ │
|
||||
│ ────────────────────────────────────────────────────── │
|
||||
│ 75% current skill, 25% review • Recommended scaffolding │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
[Click to expand]
|
||||
↓
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Difficulty: Mastery - Two-digit ones regrouping ▲ │
|
||||
│ ────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 📊 Worksheet Mix (20 problems) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ 15 problems │ Two-digit + ones regrouping │ 75% │
|
||||
│ │ (current) │ Example: 38 + 27 │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ 5 problems │ Review: Mixed mastered skills │ 25% │
|
||||
│ │ (review) │ • 2 problems: Single-digit │ │
|
||||
│ │ │ • 3 problems: Two-digit simple│ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⚙️ Scaffolding │
|
||||
│ Always: answer boxes, place value colors │
|
||||
│ When regrouping: carry boxes, ten-frames │
|
||||
│ │
|
||||
│ [ View All Skills ] [ Customize Mix ] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component**: `MasteryDifficultyDropdown.tsx`
|
||||
|
||||
```typescript
|
||||
export function MasteryDifficultyDropdown({
|
||||
currentSkill,
|
||||
masteryStates,
|
||||
totalProblems,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: MasteryDifficultyDropdownProps) {
|
||||
const masteredSkills = getMasteredSkills(masteryStates, currentSkill.operator);
|
||||
const currentCount = Math.floor(totalProblems * 0.75);
|
||||
const reviewCount = totalProblems - currentCount;
|
||||
|
||||
return (
|
||||
<div data-component="mastery-difficulty-dropdown">
|
||||
{/* Collapsed summary */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
data-element="dropdown-toggle"
|
||||
className={css({
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "1rem",
|
||||
border: "1px solid",
|
||||
borderColor: "gray.300",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "white",
|
||||
cursor: "pointer",
|
||||
_hover: { backgroundColor: "gray.50" }
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div className={css({ fontWeight: "600" })}>
|
||||
Difficulty: Mastery - {currentSkill.name}
|
||||
</div>
|
||||
<div className={css({ fontSize: "0.875rem", color: "gray.600", marginTop: "0.25rem" })}>
|
||||
{currentCount} current skill, {reviewCount} review
|
||||
{masteredSkills.length > 0 && ` from ${masteredSkills.length} mastered skill${masteredSkills.length > 1 ? 's' : ''}`}
|
||||
• Recommended scaffolding
|
||||
</div>
|
||||
</div>
|
||||
<div>{isExpanded ? "▲" : "▼"}</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
{isExpanded && (
|
||||
<div data-element="dropdown-content" className={css({
|
||||
padding: "1rem",
|
||||
border: "1px solid",
|
||||
borderColor: "gray.300",
|
||||
borderTop: "none",
|
||||
borderBottomLeftRadius: "8px",
|
||||
borderBottomRightRadius: "8px"
|
||||
})}>
|
||||
<MasteryModePanel
|
||||
currentSkill={currentSkill}
|
||||
masteryStates={masteryStates}
|
||||
totalProblems={totalProblems}
|
||||
onCustomize={() => {/* Open customize modal */}}
|
||||
onViewAllSkills={() => {/* Open skills modal */}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. All Skills Modal
|
||||
|
||||
**Trigger**: "View All Skills" button
|
||||
|
||||
**Purpose**: Show complete skill progression and mastery state
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Skill Progression - Addition × │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Single-digit Skills │
|
||||
│ ✓ No regrouping (3+5, 2+4) │
|
||||
│ Mastered on Jan 15, 2025 │
|
||||
│ │
|
||||
│ ✓ Simple regrouping (7+8, 9+6) │
|
||||
│ Mastered on Jan 22, 2025 │
|
||||
│ │
|
||||
│ Two-digit Skills │
|
||||
│ ✓ No regrouping (23+45, 31+28) │
|
||||
│ Mastered on Feb 1, 2025 │
|
||||
│ │
|
||||
│ ► Ones place regrouping (38+27, 49+15) ⭐ │
|
||||
│ Current skill • 12 attempts • 78% accuracy │
|
||||
│ [Mark as Mastered] [Practice This] │
|
||||
│ │
|
||||
│ ○ Mixed regrouping (67+58, 84+73) │
|
||||
│ Not started • Requires: ones place regrouping │
|
||||
│ │
|
||||
│ ⊘ Full regrouping (88+99, 76+67) │
|
||||
│ Locked • Requires: mixed regrouping │
|
||||
│ │
|
||||
│ Three-digit Skills │
|
||||
│ ⊘ No regrouping (234+451) │
|
||||
│ Locked • Requires: two-digit full regrouping │
|
||||
│ │
|
||||
│ ... (more skills) │
|
||||
│ │
|
||||
│ Progress: 3/11 skills mastered (27%) │
|
||||
│ │
|
||||
│ [Close] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component**: `AllSkillsModal.tsx`
|
||||
|
||||
```typescript
|
||||
export function AllSkillsModal({
|
||||
operator,
|
||||
masteryStates,
|
||||
currentSkillId,
|
||||
onClose,
|
||||
onSelectSkill,
|
||||
onToggleMastery,
|
||||
}: AllSkillsModalProps) {
|
||||
const skills = SKILL_DEFINITIONS.filter(s => s.operator === operator);
|
||||
const groupedSkills = groupSkillsByDigitLevel(skills);
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={`Skill Progression - ${operator}`}>
|
||||
<div data-component="all-skills-modal">
|
||||
{Object.entries(groupedSkills).map(([level, skillsInLevel]) => (
|
||||
<div key={level} data-section={`skill-group-${level}`}>
|
||||
<h4>{level}</h4>
|
||||
|
||||
{skillsInLevel.map(skill => {
|
||||
const state = masteryStates.get(skill.id);
|
||||
const isMastered = state?.isMastered ?? false;
|
||||
const isCurrent = skill.id === currentSkillId;
|
||||
const prereqsMet = checkPrerequisites(skill, masteryStates);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
data-element="skill-card"
|
||||
className={css({
|
||||
padding: "1rem",
|
||||
border: "1px solid",
|
||||
borderColor: isCurrent ? "blue.500" : "gray.300",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "0.5rem",
|
||||
backgroundColor: isCurrent ? "blue.50" : "white"
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: "flex", alignItems: "flex-start", gap: "0.5rem" })}>
|
||||
{/* Status icon */}
|
||||
<div className={css({ fontSize: "1.25rem" })}>
|
||||
{isMastered ? "✓" : prereqsMet ? "►" : "⊘"}
|
||||
</div>
|
||||
|
||||
{/* Skill info */}
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "0.5rem" })}>
|
||||
<strong>{skill.name}</strong>
|
||||
{isCurrent && <span className={css({ fontSize: "1.25rem" })}>⭐</span>}
|
||||
</div>
|
||||
|
||||
<div className={css({ fontSize: "0.875rem", color: "gray.600", marginTop: "0.25rem" })}>
|
||||
{skill.description}
|
||||
</div>
|
||||
|
||||
{/* Mastery status */}
|
||||
{isMastered && state?.masteredAt && (
|
||||
<div className={css({ fontSize: "0.75rem", color: "green.600", marginTop: "0.25rem" })}>
|
||||
Mastered on {formatDate(state.masteredAt)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrent && state && !isMastered && (
|
||||
<div className={css({ fontSize: "0.75rem", color: "gray.600", marginTop: "0.25rem" })}>
|
||||
Current skill • {state.totalAttempts} attempts • {Math.round((state.lastAccuracy ?? 0) * 100)}% accuracy
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMastered && !isCurrent && !prereqsMet && (
|
||||
<div className={css({ fontSize: "0.75rem", color: "gray.500", marginTop: "0.25rem" })}>
|
||||
Locked • Requires: {skill.prerequisites.map(id => getSkillName(id)).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMastered && !isCurrent && prereqsMet && (
|
||||
<div className={css({ fontSize: "0.75rem", color: "gray.600", marginTop: "0.25rem" })}>
|
||||
Not started • Prerequisites met
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{prereqsMet && (
|
||||
<div className={css({ display: "flex", gap: "0.5rem", marginTop: "0.5rem" })}>
|
||||
{!isMastered && (
|
||||
<button
|
||||
onClick={() => onToggleMastery(skill.id, true)}
|
||||
className={css({ /* button styles */ })}
|
||||
>
|
||||
Mark as Mastered
|
||||
</button>
|
||||
)}
|
||||
{isMastered && (
|
||||
<button
|
||||
onClick={() => onToggleMastery(skill.id, false)}
|
||||
className={css({ /* button styles */ })}
|
||||
>
|
||||
Unmark
|
||||
</button>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => onSelectSkill(skill.id)}
|
||||
className={css({ /* button styles */ })}
|
||||
>
|
||||
Practice This
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Progress summary */}
|
||||
<div data-section="progress-summary" className={css({
|
||||
marginTop: "1rem",
|
||||
padding: "1rem",
|
||||
backgroundColor: "gray.100",
|
||||
borderRadius: "8px"
|
||||
})}>
|
||||
<strong>Progress: {calculateMasteredCount(masteryStates, operator)}/{skills.length} skills mastered ({calculateProgressPercentage(masteryStates, operator)}%)</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Customize Mix Modal
|
||||
|
||||
**Trigger**: "Customize Mix" button
|
||||
|
||||
**Purpose**: Allow users to adjust mix percentages and manually select review skills
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Customize Worksheet Mix × │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Mix Ratio │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Current Skill: [====75%====] │ │
|
||||
│ │ Review: [===25%===] │ │
|
||||
│ │ │ │
|
||||
│ │ Slider: 50% ←────●────→ 100% │ │
|
||||
│ │ (more review) (more current) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Review Skills (auto-selected from mastered) │
|
||||
│ ☑ Single-digit no regrouping │
|
||||
│ ☑ Single-digit simple regrouping │
|
||||
│ ☑ Two-digit no regrouping │
|
||||
│ │
|
||||
│ [ Reset to Default ] [ Apply ] [ Cancel ] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component**: `CustomizeMixModal.tsx`
|
||||
|
||||
```typescript
|
||||
export function CustomizeMixModal({
|
||||
currentSkill,
|
||||
masteredSkills,
|
||||
currentMixRatio,
|
||||
selectedReviewSkills,
|
||||
onApply,
|
||||
onClose,
|
||||
}: CustomizeMixModalProps) {
|
||||
const [ratio, setRatio] = useState(currentMixRatio); // 0.5-1.0 (50%-100% current)
|
||||
const [reviewSkills, setReviewSkills] = useState(selectedReviewSkills);
|
||||
|
||||
const currentPercentage = Math.round(ratio * 100);
|
||||
const reviewPercentage = 100 - currentPercentage;
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title="Customize Worksheet Mix">
|
||||
<div data-component="customize-mix-modal">
|
||||
{/* Mix ratio slider */}
|
||||
<div data-section="mix-ratio">
|
||||
<h4>Mix Ratio</h4>
|
||||
|
||||
<div className={css({ marginBottom: "1rem" })}>
|
||||
<div className={css({ display: "flex", justifyContent: "space-between", fontSize: "0.875rem" })}>
|
||||
<span>Current Skill: {currentPercentage}%</span>
|
||||
<span>Review: {reviewPercentage}%</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
value={currentPercentage}
|
||||
onChange={(e) => setRatio(Number(e.target.value) / 100)}
|
||||
className={css({ width: "100%", marginTop: "0.5rem" })}
|
||||
/>
|
||||
|
||||
<div className={css({ display: "flex", justifyContent: "space-between", fontSize: "0.75rem", color: "gray.600", marginTop: "0.25rem" })}>
|
||||
<span>More review</span>
|
||||
<span>More current skill</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review skills selection */}
|
||||
<div data-section="review-skills">
|
||||
<h4>Review Skills {masteredSkills.length === 0 && "(none mastered yet)"}</h4>
|
||||
|
||||
{masteredSkills.length > 0 ? (
|
||||
<div>
|
||||
<p className={css({ fontSize: "0.875rem", color: "gray.600", marginBottom: "0.5rem" })}>
|
||||
Select which mastered skills to include in review problems
|
||||
</p>
|
||||
|
||||
{masteredSkills.map(skill => (
|
||||
<label
|
||||
key={skill.id}
|
||||
className={css({ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem" })}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reviewSkills.includes(skill.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setReviewSkills([...reviewSkills, skill.id]);
|
||||
} else {
|
||||
setReviewSkills(reviewSkills.filter(id => id !== skill.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{skill.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className={css({ fontSize: "0.875rem", color: "gray.600" })}>
|
||||
No mastered skills yet. Complete some skills to enable review mix.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div data-section="actions" className={css({
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
marginTop: "1rem",
|
||||
justifyContent: "flex-end"
|
||||
})}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRatio(0.75);
|
||||
setReviewSkills(masteredSkills.map(s => s.id));
|
||||
}}
|
||||
className={css({ /* button styles */ })}
|
||||
>
|
||||
Reset to Default
|
||||
</button>
|
||||
<button onClick={onClose} className={css({ /* button styles */ })}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onApply({ ratio, reviewSkills })}
|
||||
className={css({ /* button styles */ })}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Inline Problem Attribution (Preview)
|
||||
|
||||
**In the worksheet preview**, add subtle indicators showing which problems are current vs review.
|
||||
|
||||
```
|
||||
Preview:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Problem 1 [Current] │
|
||||
│ 38 │
|
||||
│ + 27 │
|
||||
│ ---- │
|
||||
│ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Problem 2 [Review: sd] │
|
||||
│ 7 │
|
||||
│ + 8 │
|
||||
│ ---- │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation**: Add subtle badge or color coding to problem numbers in preview
|
||||
|
||||
```typescript
|
||||
// In worksheet preview rendering
|
||||
function renderProblemWithAttribution(problem: WorksheetProblem, index: number) {
|
||||
const isReview = problem.metadata?.isReview ?? false;
|
||||
const reviewSkillName = problem.metadata?.skillName;
|
||||
|
||||
return (
|
||||
<div data-element="problem-card">
|
||||
<div className={css({ display: "flex", justifyContent: "space-between", alignItems: "center" })}>
|
||||
<span>Problem {index + 1}</span>
|
||||
{isReview ? (
|
||||
<span className={css({
|
||||
fontSize: "0.75rem",
|
||||
color: "green.600",
|
||||
backgroundColor: "green.50",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "4px"
|
||||
})}>
|
||||
Review: {reviewSkillName}
|
||||
</span>
|
||||
) : (
|
||||
<span className={css({
|
||||
fontSize: "0.75rem",
|
||||
color: "blue.600",
|
||||
backgroundColor: "blue.50",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "4px"
|
||||
})}>
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Problem rendering */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Observability Features Summary
|
||||
|
||||
### 1. **What's in the mix**
|
||||
- Current skill count + percentage
|
||||
- Review count + percentage
|
||||
- Breakdown of review by skill (e.g., "2 single-digit, 3 two-digit")
|
||||
|
||||
### 2. **Why this mix**
|
||||
- "Recommended scaffolding for this skill"
|
||||
- "Prerequisites: [list]"
|
||||
- "Next skill: [name] (after mastering this)"
|
||||
|
||||
### 3. **How it's distributed**
|
||||
- Visual blocks showing current vs review ratio
|
||||
- List showing exact count per review skill
|
||||
- Example problems for each skill type
|
||||
|
||||
### 4. **Progress tracking**
|
||||
- X/Y skills mastered
|
||||
- Percentage complete
|
||||
- Last practiced date
|
||||
- Accuracy tracking
|
||||
|
||||
### 5. **Control transparency**
|
||||
- Ability to customize mix ratio
|
||||
- Ability to select specific review skills
|
||||
- Ability to manually override mastery status
|
||||
- Ability to skip to different skill
|
||||
|
||||
---
|
||||
|
||||
## Layout Integration
|
||||
|
||||
### Current Smart Mode Controls Location
|
||||
|
||||
```
|
||||
ConfigPanel.tsx
|
||||
├─ ModeSelector (Smart/Manual/Mastery) [NEW]
|
||||
├─ if mode === 'smart' && !masteryMode
|
||||
│ └─ SmartModeControls (difficulty slider, make harder/easier)
|
||||
├─ if mode === 'smart' && masteryMode
|
||||
│ └─ MasteryModePanel [NEW]
|
||||
└─ if mode === 'manual'
|
||||
└─ ManualModeControls (toggle switches)
|
||||
```
|
||||
|
||||
### Collapsed State (Preset Dropdown Location)
|
||||
|
||||
Current location in `AdditionWorksheetClient.tsx`:
|
||||
|
||||
```typescript
|
||||
{/* Difficulty preset dropdown (collapsed state) */}
|
||||
{formState.mode === "smart" && !formState.masteryMode && (
|
||||
<DifficultyPresetDropdown ... />
|
||||
)}
|
||||
|
||||
{formState.mode === "smart" && formState.masteryMode && (
|
||||
<MasteryDifficultyDropdown ... /> // NEW
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Desktop (≥768px)
|
||||
- Full panel with all details visible
|
||||
- Modals centered, max-width 600px
|
||||
|
||||
### Tablet (480px - 768px)
|
||||
- Compact panel, abbreviated text
|
||||
- Modals full-width with padding
|
||||
|
||||
### Mobile (<480px)
|
||||
- Stacked layout
|
||||
- Abbreviated labels ("Cur: 15" instead of "Current: 15 problems")
|
||||
- Full-screen modals
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
1. **Keyboard navigation**: All modals and buttons keyboard-accessible
|
||||
2. **Screen readers**: Proper ARIA labels on all interactive elements
|
||||
3. **Color contrast**: Ensure blue/green badges meet WCAG AA standards
|
||||
4. **Focus management**: Return focus to trigger button when modals close
|
||||
5. **Status announcements**: Announce mastery status changes via aria-live
|
||||
|
||||
---
|
||||
|
||||
## Animation/Polish
|
||||
|
||||
1. **Smooth expand/collapse**: Dropdown transition (200ms ease-in-out)
|
||||
2. **Progress indication**: Skill progress bar fills left-to-right
|
||||
3. **Badge animations**: Subtle pulse on "current skill" indicator
|
||||
4. **Modal transitions**: Fade in (150ms) + slide up slightly
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User selects Mastery Mode
|
||||
↓
|
||||
Load mastery states from API (/api/worksheets/mastery)
|
||||
↓
|
||||
Calculate current skill (findNextSkill)
|
||||
↓
|
||||
Load skill definition (SKILL_DEFINITIONS[currentSkillId])
|
||||
↓
|
||||
Calculate mix (75% current, 25% review breakdown)
|
||||
↓
|
||||
Update UI (MasteryModePanel shows mix details)
|
||||
↓
|
||||
Generate preview (generateMasteryWorksheet with metadata)
|
||||
↓
|
||||
Render preview with problem attribution badges
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions for You
|
||||
|
||||
1. **Should we show problem attribution in the final PDF?**
|
||||
- Option A: Only in preview (cleaner final product)
|
||||
- Option B: In PDF as subtle watermark/footer note
|
||||
- Option C: Configurable toggle
|
||||
|
||||
2. **Review skill selection behavior**
|
||||
- Auto-select all mastered skills (current plan)
|
||||
- Or default to "most recently mastered" only?
|
||||
|
||||
3. **Mix ratio bounds**
|
||||
- Current: 50-100% current skill (so 0-50% review)
|
||||
- Should we allow 100% review (no current skill practice)?
|
||||
|
||||
4. **Manual skill override behavior**
|
||||
- If user marks skill as mastered, should we auto-advance to next skill?
|
||||
- Or stay on current skill until they explicitly change?
|
||||
@@ -0,0 +1,264 @@
|
||||
# Mastery Mode V2: Technique + Complexity Architecture
|
||||
|
||||
## Problem with V1
|
||||
|
||||
Current "skills" conflate two orthogonal concepts:
|
||||
- **Techniques** (carrying, borrowing) - actual new skills to learn
|
||||
- **Complexity** (digit count, regrouping frequency) - problem difficulty
|
||||
|
||||
This leads to artificial "skills" like "Two-digit without regrouping" which aren't really skills.
|
||||
|
||||
## V2 Architecture: Separate Techniques from Complexity
|
||||
|
||||
### Core Concepts
|
||||
|
||||
**Technique** = A mathematical procedure/algorithm
|
||||
- Carrying (regrouping) in addition
|
||||
- Borrowing (regrouping) in subtraction
|
||||
- Multi-column operations
|
||||
- Place value alignment
|
||||
|
||||
**Complexity Level** = Problem characteristics
|
||||
- Digit range (1-5 digits)
|
||||
- Regrouping frequency (never, sometimes, always)
|
||||
- Regrouping positions (ones only, tens only, multiple places)
|
||||
|
||||
**Scaffolding Level** = Amount of visual support (affects learning progression)
|
||||
- With ten-frames (visual scaffolding for regrouping)
|
||||
- Without ten-frames (internalized concept)
|
||||
- With carry/borrow notation
|
||||
- Without carry/borrow notation
|
||||
|
||||
**Key Insight**: Scaffolding should CYCLE as complexity increases!
|
||||
- 2-digit regrouping WITH ten-frames → 2-digit regrouping WITHOUT ten-frames
|
||||
- 3-digit regrouping WITH ten-frames → 3-digit regrouping WITHOUT ten-frames
|
||||
- Pattern repeats: new complexity = reintroduce scaffolding, then fade it
|
||||
|
||||
**Practice Objective** = Technique × Complexity
|
||||
- "Practice carrying with 2-digit problems where only ones place regroups"
|
||||
- "Practice basic addition with 3-digit numbers (no carrying)"
|
||||
|
||||
### Data Model
|
||||
|
||||
```typescript
|
||||
// Core technique being practiced
|
||||
interface Technique {
|
||||
id: TechniqueId
|
||||
name: string
|
||||
description: string
|
||||
operator: 'addition' | 'subtraction'
|
||||
|
||||
// Prerequisites are OTHER techniques
|
||||
prerequisites: TechniqueId[]
|
||||
|
||||
// Recommended scaffolding for this technique
|
||||
recommendedScaffolding: Partial<DisplayRules>
|
||||
}
|
||||
|
||||
// Problem complexity configuration
|
||||
interface ComplexityLevel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
|
||||
// Problem generation parameters
|
||||
digitRange: { min: number; max: number }
|
||||
regroupingConfig: { pAnyStart: number; pAllStart: number }
|
||||
|
||||
// Scaffolding adjustments for this complexity
|
||||
scaffoldingAdjustments?: Partial<DisplayRules>
|
||||
}
|
||||
|
||||
// Scaffolding level (affects learning progression)
|
||||
type ScaffoldingLevel = 'full' | 'partial' | 'minimal'
|
||||
|
||||
// A learnable practice objective
|
||||
interface PracticeObjective {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
|
||||
// What technique is being practiced?
|
||||
technique: TechniqueId
|
||||
|
||||
// At what complexity level?
|
||||
complexity: ComplexityLevel
|
||||
|
||||
// With what scaffolding level?
|
||||
scaffolding: ScaffoldingLevel
|
||||
|
||||
// Scaffolding overrides for this specific objective
|
||||
// Example: 'full' = tenFrames: 'whenRegrouping', 'minimal' = tenFrames: 'never'
|
||||
scaffoldingOverrides: Partial<DisplayRules>
|
||||
|
||||
// Final config = technique.scaffolding + complexity.adjustments + scaffolding.overrides
|
||||
|
||||
// Mastery tracking
|
||||
masteryThreshold: number
|
||||
minimumAttempts: number
|
||||
|
||||
// What objectives should come next?
|
||||
// Usually: same technique+complexity with less scaffolding, OR
|
||||
// same technique with higher complexity and full scaffolding
|
||||
nextObjectives: string[]
|
||||
}
|
||||
|
||||
// Technique IDs
|
||||
type TechniqueId =
|
||||
| 'basic-addition' // No carrying
|
||||
| 'single-carry' // Carrying in one place
|
||||
| 'multi-carry' // Carrying in multiple places
|
||||
| 'basic-subtraction' // No borrowing
|
||||
| 'single-borrow' // Borrowing from one place
|
||||
| 'multi-borrow' // Borrowing across multiple places
|
||||
```
|
||||
|
||||
### Scaffolding Fade Pattern
|
||||
|
||||
For each (technique × complexity) combination, progression follows:
|
||||
|
||||
```
|
||||
New Complexity Level:
|
||||
1. WITH scaffolding (ten-frames, carry boxes) ← learn the pattern
|
||||
2. WITHOUT scaffolding ← internalize the concept
|
||||
3. Move to next complexity → restart at step 1
|
||||
```
|
||||
|
||||
**Example: Single-carry technique across complexities**
|
||||
|
||||
| Step | Complexity | Ten-frames | Mastery Goal |
|
||||
|------|------------|------------|--------------|
|
||||
| 1 | 2-digit (ones) | YES | Learn carrying with visual support |
|
||||
| 2 | 2-digit (ones) | NO | Internalize without support |
|
||||
| 3 | 3-digit (ones) | YES | Apply to new complexity with support |
|
||||
| 4 | 3-digit (ones) | NO | Internalize at new complexity |
|
||||
| 5 | 3-digit (multiple) | YES | More complex regrouping with support |
|
||||
| 6 | 3-digit (multiple) | NO | Internalize complex regrouping |
|
||||
|
||||
This creates **mini-cycles of scaffolding** rather than permanently removing support.
|
||||
|
||||
### Example: Progression Path
|
||||
|
||||
#### Addition Techniques
|
||||
1. **Basic Addition** (no carrying)
|
||||
- Complexity: Single-digit → Two-digit → Three-digit
|
||||
- Scaffolding: Answer boxes, place value colors
|
||||
- Carry boxes: 'always' (show structure) or 'never' (cleaner)
|
||||
|
||||
2. **Single-place Carrying**
|
||||
- Complexity: Two-digit (ones) → Three-digit (ones) → Three-digit (tens)
|
||||
- Scaffolding: Carry boxes 'whenRegrouping', ten-frames 'whenRegrouping'
|
||||
|
||||
3. **Multi-place Carrying**
|
||||
- Complexity: Three-digit → Four-digit → Five-digit
|
||||
- Scaffolding: Carry boxes 'whenMultipleRegroups'
|
||||
|
||||
#### Subtraction Techniques
|
||||
1. **Basic Subtraction** (no borrowing)
|
||||
2. **Single-place Borrowing**
|
||||
3. **Multi-place Borrowing**
|
||||
|
||||
### Complexity Progression
|
||||
|
||||
For each technique, students progress through complexity:
|
||||
|
||||
```typescript
|
||||
const complexityLevels: ComplexityLevel[] = [
|
||||
{
|
||||
id: 'single-digit',
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
},
|
||||
{
|
||||
id: 'two-digit-no-regroup',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
scaffoldingAdjustments: {
|
||||
carryBoxes: 'always', // Show structure even when not carrying
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'two-digit-ones-regroup',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0 },
|
||||
},
|
||||
{
|
||||
id: 'two-digit-all-regroup',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 1.0 },
|
||||
},
|
||||
// ... etc
|
||||
]
|
||||
```
|
||||
|
||||
### Benefits of This Model
|
||||
|
||||
1. **Clarity**: Clear separation between "learning new techniques" vs "practicing with harder numbers"
|
||||
2. **Flexibility**: Same technique can be practiced at different complexity levels
|
||||
3. **Scaffolding**: Technique defines baseline scaffolding, complexity can adjust
|
||||
4. **Progression**: Natural progression: master technique at low complexity → increase complexity
|
||||
5. **No fake skills**: "Two-digit without regrouping" is just complexity, not a technique
|
||||
|
||||
### UI Changes
|
||||
|
||||
#### Current (V1):
|
||||
```
|
||||
Skill: "Two-digit with ones place regrouping" ← confusing
|
||||
```
|
||||
|
||||
#### New (V2):
|
||||
```
|
||||
Technique: Single-place Carrying
|
||||
Complexity: Two-digit (ones place only)
|
||||
```
|
||||
|
||||
Or simplified:
|
||||
```
|
||||
Practice: Carrying (ones place, 2-digit problems)
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Phase 1**: New data structures (technique, complexity, objective)
|
||||
2. **Phase 2**: Map existing 21 skills → technique + complexity combinations
|
||||
3. **Phase 3**: Update UI to show "Technique + Complexity" instead of "Skill"
|
||||
4. **Phase 4**: Migrate database mastery tracking (backwards compatible)
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. **Should complexity be auto-progressive?**
|
||||
- Option A: Track mastery per (technique × complexity) separately
|
||||
- Option B: Once technique is mastered at any complexity, auto-advance complexity
|
||||
|
||||
2. **How to handle "show structure" scaffolding?**
|
||||
- "Two-digit without regrouping" wants carryBoxes='always' to show structure
|
||||
- But that's a complexity-level scaffolding choice, not technique-level
|
||||
|
||||
3. **Review mixing?**
|
||||
- Current: Review previous skills
|
||||
- New: Review previous techniques? Or previous complexity levels?
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Define Core Types
|
||||
Create `techniques.ts` and `complexityLevels.ts`
|
||||
|
||||
### Step 2: Create Mapping
|
||||
Map current 21 skills → (technique, complexity) pairs
|
||||
|
||||
### Step 3: Update UI
|
||||
- MasteryModePanel shows technique + complexity
|
||||
- AllSkillsModal groups by technique, shows complexity progression
|
||||
|
||||
### Step 4: Database Migration
|
||||
- Keep `worksheet_mastery.skill_id` for backwards compatibility
|
||||
- Or migrate to `technique_id` + `complexity_id`
|
||||
|
||||
### Step 5: Worksheet Generation
|
||||
- Select technique → get base scaffolding
|
||||
- Select complexity → get problem generation params + scaffolding adjustments
|
||||
- Merge into final config
|
||||
|
||||
---
|
||||
|
||||
**Status**: Design phase - awaiting approval before implementation
|
||||
@@ -0,0 +1,339 @@
|
||||
// Tests for progression path utilities
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SINGLE_CARRY_PATH,
|
||||
configMatchesStep,
|
||||
findNearestStep,
|
||||
getSliderValueFromStep,
|
||||
getStepById,
|
||||
getStepFromSliderValue,
|
||||
type ProgressionStep,
|
||||
} from '../progressionPath'
|
||||
|
||||
describe('progressionPath', () => {
|
||||
describe('SINGLE_CARRY_PATH', () => {
|
||||
it('should have 6 steps', () => {
|
||||
expect(SINGLE_CARRY_PATH).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('should have consecutive step numbers', () => {
|
||||
SINGLE_CARRY_PATH.forEach((step, index) => {
|
||||
expect(step.stepNumber).toBe(index)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have correct next/previous links', () => {
|
||||
// First step
|
||||
expect(SINGLE_CARRY_PATH[0].previousStepId).toBe(null)
|
||||
expect(SINGLE_CARRY_PATH[0].nextStepId).toBe('single-carry-1d-minimal')
|
||||
|
||||
// Middle steps
|
||||
expect(SINGLE_CARRY_PATH[1].previousStepId).toBe('single-carry-1d-full')
|
||||
expect(SINGLE_CARRY_PATH[1].nextStepId).toBe('single-carry-2d-full')
|
||||
|
||||
// Last step
|
||||
expect(SINGLE_CARRY_PATH[5].previousStepId).toBe('single-carry-3d-full')
|
||||
expect(SINGLE_CARRY_PATH[5].nextStepId).toBe(null)
|
||||
})
|
||||
|
||||
it('should demonstrate scaffolding cycling', () => {
|
||||
// 1-digit: full → minimal
|
||||
expect(SINGLE_CARRY_PATH[0].config.displayRules?.tenFrames).toBe('whenRegrouping')
|
||||
expect(SINGLE_CARRY_PATH[1].config.displayRules?.tenFrames).toBe('never')
|
||||
|
||||
// 2-digit: full → minimal (ten-frames RETURN)
|
||||
expect(SINGLE_CARRY_PATH[2].config.displayRules?.tenFrames).toBe('whenRegrouping')
|
||||
expect(SINGLE_CARRY_PATH[3].config.displayRules?.tenFrames).toBe('never')
|
||||
|
||||
// 3-digit: full → minimal (ten-frames RETURN AGAIN)
|
||||
expect(SINGLE_CARRY_PATH[4].config.displayRules?.tenFrames).toBe('whenRegrouping')
|
||||
expect(SINGLE_CARRY_PATH[5].config.displayRules?.tenFrames).toBe('never')
|
||||
})
|
||||
|
||||
it('should have increasing digit complexity', () => {
|
||||
// 1-digit (steps 0-1)
|
||||
expect(SINGLE_CARRY_PATH[0].config.digitRange?.min).toBe(1)
|
||||
expect(SINGLE_CARRY_PATH[0].config.digitRange?.max).toBe(1)
|
||||
expect(SINGLE_CARRY_PATH[1].config.digitRange?.min).toBe(1)
|
||||
|
||||
// 2-digit (steps 2-3)
|
||||
expect(SINGLE_CARRY_PATH[2].config.digitRange?.min).toBe(2)
|
||||
expect(SINGLE_CARRY_PATH[2].config.digitRange?.max).toBe(2)
|
||||
expect(SINGLE_CARRY_PATH[3].config.digitRange?.min).toBe(2)
|
||||
|
||||
// 3-digit (steps 4-5)
|
||||
expect(SINGLE_CARRY_PATH[4].config.digitRange?.min).toBe(3)
|
||||
expect(SINGLE_CARRY_PATH[4].config.digitRange?.max).toBe(3)
|
||||
expect(SINGLE_CARRY_PATH[5].config.digitRange?.min).toBe(3)
|
||||
})
|
||||
|
||||
it('should have consistent regrouping config', () => {
|
||||
// All steps have 100% regrouping, ones place only
|
||||
SINGLE_CARRY_PATH.forEach((step) => {
|
||||
expect(step.config.pAnyStart).toBe(1.0)
|
||||
expect(step.config.pAllStart).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should all be addition operator', () => {
|
||||
SINGLE_CARRY_PATH.forEach((step) => {
|
||||
expect(step.config.operator).toBe('addition')
|
||||
})
|
||||
})
|
||||
|
||||
it('should all be single-carry technique', () => {
|
||||
SINGLE_CARRY_PATH.forEach((step) => {
|
||||
expect(step.technique).toBe('single-carry')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have interpolate disabled', () => {
|
||||
// Mastery mode = no progressive difficulty
|
||||
SINGLE_CARRY_PATH.forEach((step) => {
|
||||
expect(step.config.interpolate).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStepFromSliderValue', () => {
|
||||
it('should return first step for value 0', () => {
|
||||
const step = getStepFromSliderValue(0, SINGLE_CARRY_PATH)
|
||||
expect(step.stepNumber).toBe(0)
|
||||
expect(step.id).toBe('single-carry-1d-full')
|
||||
})
|
||||
|
||||
it('should return last step for value 100', () => {
|
||||
const step = getStepFromSliderValue(100, SINGLE_CARRY_PATH)
|
||||
expect(step.stepNumber).toBe(5)
|
||||
expect(step.id).toBe('single-carry-3d-minimal')
|
||||
})
|
||||
|
||||
it('should return middle steps for middle values', () => {
|
||||
// 6 steps → positions at 0, 20, 40, 60, 80, 100
|
||||
const step1 = getStepFromSliderValue(20, SINGLE_CARRY_PATH)
|
||||
expect(step1.stepNumber).toBe(1)
|
||||
|
||||
const step2 = getStepFromSliderValue(40, SINGLE_CARRY_PATH)
|
||||
expect(step2.stepNumber).toBe(2)
|
||||
|
||||
const step3 = getStepFromSliderValue(60, SINGLE_CARRY_PATH)
|
||||
expect(step3.stepNumber).toBe(3)
|
||||
})
|
||||
|
||||
it('should round to nearest step', () => {
|
||||
// 6 steps → positions at 0, 20, 40, 60, 80, 100
|
||||
// Value 30: (30/100) * 5 = 1.5 → rounds to 2
|
||||
const step = getStepFromSliderValue(30, SINGLE_CARRY_PATH)
|
||||
expect(step.stepNumber).toBe(2)
|
||||
|
||||
// Value 10: (10/100) * 5 = 0.5 → rounds to 1
|
||||
const step2 = getStepFromSliderValue(10, SINGLE_CARRY_PATH)
|
||||
expect(step2.stepNumber).toBe(1)
|
||||
|
||||
// Value 50: (50/100) * 5 = 2.5 → rounds to 3
|
||||
const step3 = getStepFromSliderValue(50, SINGLE_CARRY_PATH)
|
||||
expect(step3.stepNumber).toBe(3)
|
||||
})
|
||||
|
||||
it('should clamp values below 0 to first step', () => {
|
||||
const step = getStepFromSliderValue(-10, SINGLE_CARRY_PATH)
|
||||
expect(step.stepNumber).toBe(0)
|
||||
})
|
||||
|
||||
it('should clamp values above 100 to last step', () => {
|
||||
const step = getStepFromSliderValue(150, SINGLE_CARRY_PATH)
|
||||
expect(step.stepNumber).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSliderValueFromStep', () => {
|
||||
it('should return 0 for first step', () => {
|
||||
const value = getSliderValueFromStep(0, SINGLE_CARRY_PATH.length)
|
||||
expect(value).toBe(0)
|
||||
})
|
||||
|
||||
it('should return 100 for last step', () => {
|
||||
const value = getSliderValueFromStep(5, SINGLE_CARRY_PATH.length)
|
||||
expect(value).toBe(100)
|
||||
})
|
||||
|
||||
it('should return evenly spaced values for middle steps', () => {
|
||||
// 6 steps → 0, 20, 40, 60, 80, 100
|
||||
expect(getSliderValueFromStep(0, 6)).toBe(0)
|
||||
expect(getSliderValueFromStep(1, 6)).toBe(20)
|
||||
expect(getSliderValueFromStep(2, 6)).toBe(40)
|
||||
expect(getSliderValueFromStep(3, 6)).toBe(60)
|
||||
expect(getSliderValueFromStep(4, 6)).toBe(80)
|
||||
expect(getSliderValueFromStep(5, 6)).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle single-step path', () => {
|
||||
const value = getSliderValueFromStep(0, 1)
|
||||
expect(value).toBe(0)
|
||||
})
|
||||
|
||||
it('should be inverse of getStepFromSliderValue', () => {
|
||||
// Round-trip should preserve step number
|
||||
for (let stepNum = 0; stepNum < SINGLE_CARRY_PATH.length; stepNum++) {
|
||||
const sliderValue = getSliderValueFromStep(stepNum, SINGLE_CARRY_PATH.length)
|
||||
const step = getStepFromSliderValue(sliderValue, SINGLE_CARRY_PATH)
|
||||
expect(step.stepNumber).toBe(stepNum)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('findNearestStep', () => {
|
||||
it('should find exact match for step config', () => {
|
||||
const step2Config = SINGLE_CARRY_PATH[2].config
|
||||
const nearest = findNearestStep(step2Config, SINGLE_CARRY_PATH)
|
||||
expect(nearest.stepNumber).toBe(2)
|
||||
expect(nearest.id).toBe('single-carry-2d-full')
|
||||
})
|
||||
|
||||
it('should prioritize digit range matching', () => {
|
||||
// Config with 3-digit but wrong scaffolding
|
||||
// Use a complete displayRules object from an existing step
|
||||
const baseDisplayRules = SINGLE_CARRY_PATH[0].config.displayRules!
|
||||
const config = {
|
||||
digitRange: { min: 3, max: 3 },
|
||||
operator: 'addition' as const,
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
...baseDisplayRules,
|
||||
tenFrames: 'always' as const, // Wrong, but digit range matches
|
||||
},
|
||||
}
|
||||
|
||||
const nearest = findNearestStep(config, SINGLE_CARRY_PATH)
|
||||
// Should match step 4 or 5 (both 3-digit)
|
||||
expect(nearest.config.digitRange?.min).toBe(3)
|
||||
})
|
||||
|
||||
it('should fall back to first step if no good match', () => {
|
||||
const config = {
|
||||
digitRange: { min: 5, max: 5 }, // No 5-digit steps
|
||||
operator: 'subtraction' as const, // Wrong operator
|
||||
pAnyStart: 0.5, // Wrong regrouping
|
||||
pAllStart: 0.5,
|
||||
}
|
||||
|
||||
const nearest = findNearestStep(config, SINGLE_CARRY_PATH)
|
||||
expect(nearest).toBeDefined() // Should still return something
|
||||
expect(nearest.stepNumber).toBe(0) // Default to first
|
||||
})
|
||||
|
||||
it('should match regrouping config when digit range matches', () => {
|
||||
// Two steps with same digit range, different scaffolding
|
||||
const baseDisplayRules = SINGLE_CARRY_PATH[2].config.displayRules!
|
||||
|
||||
const config1 = {
|
||||
digitRange: { min: 2, max: 2 },
|
||||
operator: 'addition' as const,
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
...baseDisplayRules,
|
||||
tenFrames: 'whenRegrouping' as const,
|
||||
},
|
||||
}
|
||||
|
||||
const nearest1 = findNearestStep(config1, SINGLE_CARRY_PATH)
|
||||
expect(nearest1.id).toBe('single-carry-2d-full') // Step 2
|
||||
|
||||
const config2 = {
|
||||
digitRange: { min: 2, max: 2 },
|
||||
operator: 'addition' as const,
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
...baseDisplayRules,
|
||||
tenFrames: 'never' as const,
|
||||
},
|
||||
}
|
||||
|
||||
const nearest2 = findNearestStep(config2, SINGLE_CARRY_PATH)
|
||||
expect(nearest2.id).toBe('single-carry-2d-minimal') // Step 3
|
||||
})
|
||||
})
|
||||
|
||||
describe('configMatchesStep', () => {
|
||||
it('should return true for exact match', () => {
|
||||
const step = SINGLE_CARRY_PATH[2]
|
||||
const matches = configMatchesStep(step.config, step)
|
||||
expect(matches).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false if digit range differs', () => {
|
||||
const step = SINGLE_CARRY_PATH[2]
|
||||
const config = {
|
||||
...step.config,
|
||||
digitRange: { min: 3, max: 3 }, // Different
|
||||
}
|
||||
const matches = configMatchesStep(config, step)
|
||||
expect(matches).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if regrouping config differs', () => {
|
||||
const step = SINGLE_CARRY_PATH[2]
|
||||
const config = {
|
||||
...step.config,
|
||||
pAnyStart: 0.5, // Different
|
||||
}
|
||||
const matches = configMatchesStep(config, step)
|
||||
expect(matches).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if scaffolding differs', () => {
|
||||
const step = SINGLE_CARRY_PATH[2]
|
||||
const config = {
|
||||
...step.config,
|
||||
displayRules: step.config.displayRules
|
||||
? {
|
||||
...step.config.displayRules,
|
||||
tenFrames: 'never' as const, // Different
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
const matches = configMatchesStep(config, step)
|
||||
expect(matches).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false if operator differs', () => {
|
||||
const step = SINGLE_CARRY_PATH[2]
|
||||
const config = {
|
||||
...step.config,
|
||||
operator: 'subtraction' as const, // Different
|
||||
}
|
||||
const matches = configMatchesStep(config, step)
|
||||
expect(matches).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStepById', () => {
|
||||
it('should find step by ID', () => {
|
||||
const step = getStepById('single-carry-2d-full', SINGLE_CARRY_PATH)
|
||||
expect(step).toBeDefined()
|
||||
expect(step?.stepNumber).toBe(2)
|
||||
expect(step?.config.digitRange?.min).toBe(2)
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent ID', () => {
|
||||
const step = getStepById('does-not-exist', SINGLE_CARRY_PATH)
|
||||
expect(step).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should find first step', () => {
|
||||
const step = getStepById('single-carry-1d-full', SINGLE_CARRY_PATH)
|
||||
expect(step).toBeDefined()
|
||||
expect(step?.stepNumber).toBe(0)
|
||||
})
|
||||
|
||||
it('should find last step', () => {
|
||||
const step = getStepById('single-carry-3d-minimal', SINGLE_CARRY_PATH)
|
||||
expect(step).toBeDefined()
|
||||
expect(step?.stepNumber).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
202
apps/web/src/app/create/worksheets/addition/complexityLevels.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Problem complexity levels for mastery progression
|
||||
// Complexity = how hard the problems are (digit count, regrouping frequency)
|
||||
// NOT the technique being practiced
|
||||
|
||||
import type { DisplayRules } from './displayRules'
|
||||
|
||||
/**
|
||||
* Problem complexity configuration
|
||||
* Defines how difficult the problems are (separate from what technique is being practiced)
|
||||
*/
|
||||
export interface ComplexityLevel {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
|
||||
// Problem generation parameters
|
||||
digitRange: { min: number; max: number }
|
||||
regroupingConfig: {
|
||||
pAnyStart: number // Probability any place value regroups
|
||||
pAllStart: number // Probability all place values regroup
|
||||
}
|
||||
|
||||
// Optional scaffolding adjustments for this complexity level
|
||||
// These override/merge with the technique's base scaffolding
|
||||
scaffoldingAdjustments?: Partial<DisplayRules>
|
||||
|
||||
// Recommended problem count for practice
|
||||
recommendedProblemCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard complexity progression
|
||||
* These can be combined with any technique
|
||||
*/
|
||||
export const COMPLEXITY_LEVELS: Record<string, ComplexityLevel> = {
|
||||
// ============================================================================
|
||||
// SINGLE-DIGIT COMPLEXITY
|
||||
// ============================================================================
|
||||
|
||||
'sd-no-regroup': {
|
||||
id: 'sd-no-regroup',
|
||||
name: 'Single-digit (no regrouping)',
|
||||
description: 'Single-digit problems that never require regrouping (e.g., 2+3, 4+1)',
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
'sd-with-regroup': {
|
||||
id: 'sd-with-regroup',
|
||||
name: 'Single-digit (with regrouping)',
|
||||
description: 'Single-digit problems that always require regrouping (e.g., 7+8, 9+6)',
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// TWO-DIGIT COMPLEXITY
|
||||
// ============================================================================
|
||||
|
||||
'td-no-regroup': {
|
||||
id: 'td-no-regroup',
|
||||
name: 'Two-digit (no regrouping)',
|
||||
description: 'Two-digit problems without any carrying (e.g., 23+45, 31+28)',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
scaffoldingAdjustments: {
|
||||
// Show carry boxes even when not carrying to teach columnar structure
|
||||
carryBoxes: 'always',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
},
|
||||
|
||||
'td-ones-regroup': {
|
||||
id: 'td-ones-regroup',
|
||||
name: 'Two-digit (ones place only)',
|
||||
description: 'Two-digit problems with carrying only in ones place (e.g., 38+27, 49+15)',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
'td-all-regroup': {
|
||||
id: 'td-all-regroup',
|
||||
name: 'Two-digit (all places)',
|
||||
description: 'Two-digit problems with carrying in both ones and tens (e.g., 57+68, 89+74)',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 1.0 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
'td-mixed-regroup': {
|
||||
id: 'td-mixed-regroup',
|
||||
name: 'Two-digit (mixed)',
|
||||
description: 'Mix of two-digit problems, some with carrying',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.7, pAllStart: 0.3 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// THREE-DIGIT COMPLEXITY
|
||||
// ============================================================================
|
||||
|
||||
'xd-no-regroup': {
|
||||
id: 'xd-no-regroup',
|
||||
name: 'Three-digit (no regrouping)',
|
||||
description: 'Three-digit problems without any carrying',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
scaffoldingAdjustments: {
|
||||
carryBoxes: 'always', // Show structure
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
},
|
||||
|
||||
'xd-ones-regroup': {
|
||||
id: 'xd-ones-regroup',
|
||||
name: 'Three-digit (ones only)',
|
||||
description: 'Three-digit with carrying only in ones place',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
'xd-multi-regroup': {
|
||||
id: 'xd-multi-regroup',
|
||||
name: 'Three-digit (multiple places)',
|
||||
description: 'Three-digit with carrying in 2+ places',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0.5 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
'xd-all-regroup': {
|
||||
id: 'xd-all-regroup',
|
||||
name: 'Three-digit (all places)',
|
||||
description: 'Three-digit with carrying in all three places',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 1.0 },
|
||||
recommendedProblemCount: 20,
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// FOUR & FIVE DIGIT COMPLEXITY
|
||||
// ============================================================================
|
||||
|
||||
'xxd-mixed': {
|
||||
id: 'xxd-mixed',
|
||||
name: 'Four-digit (mixed)',
|
||||
description: 'Four-digit problems with varying regrouping',
|
||||
digitRange: { min: 4, max: 4 },
|
||||
regroupingConfig: { pAnyStart: 0.8, pAllStart: 0.4 },
|
||||
scaffoldingAdjustments: {
|
||||
tenFrames: 'never', // Too complex for ten-frames
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
},
|
||||
|
||||
'xxxd-mixed': {
|
||||
id: 'xxxd-mixed',
|
||||
name: 'Five-digit (mixed)',
|
||||
description: 'Five-digit problems with varying regrouping',
|
||||
digitRange: { min: 5, max: 5 },
|
||||
regroupingConfig: { pAnyStart: 0.8, pAllStart: 0.5 },
|
||||
scaffoldingAdjustments: {
|
||||
tenFrames: 'never',
|
||||
carryBoxes: 'whenMultipleRegroups', // Only show for complex cases
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complexity level by ID
|
||||
*/
|
||||
export function getComplexityLevel(id: string): ComplexityLevel | undefined {
|
||||
return COMPLEXITY_LEVELS[id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all complexity levels sorted by difficulty
|
||||
*/
|
||||
export function getComplexityLevelsSorted(): ComplexityLevel[] {
|
||||
// Sort by digit count, then by regrouping frequency
|
||||
return Object.values(COMPLEXITY_LEVELS).sort((a, b) => {
|
||||
if (a.digitRange.min !== b.digitRange.min) {
|
||||
return a.digitRange.min - b.digitRange.min
|
||||
}
|
||||
return a.regroupingConfig.pAnyStart - b.regroupingConfig.pAnyStart
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complexity levels suitable for a digit range
|
||||
*/
|
||||
export function getComplexityLevelsForDigits(min: number, max: number): ComplexityLevel[] {
|
||||
return Object.values(COMPLEXITY_LEVELS).filter(
|
||||
(level) => level.digitRange.min >= min && level.digitRange.max <= max
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import * as Tabs from '@radix-ui/react-tabs'
|
||||
import * as Accordion from '@radix-ui/react-accordion'
|
||||
import * as Progress from '@radix-ui/react-progress'
|
||||
import * as Checkbox from '@radix-ui/react-checkbox'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { css } from '../../../../../../../styled-system/css'
|
||||
import type { SkillId, SkillDefinition } from '../../skills'
|
||||
|
||||
interface AllSkillsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
skills: SkillDefinition[]
|
||||
currentSkillId: SkillId
|
||||
masteryStates: Map<SkillId, boolean>
|
||||
onSelectSkill: (skillId: SkillId) => void
|
||||
onToggleMastery: (skillId: SkillId, isMastered: boolean) => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
type FilterTab = 'all' | 'mastered' | 'available' | 'locked'
|
||||
|
||||
/**
|
||||
* All Skills Modal - Skills Mastery Dashboard
|
||||
*
|
||||
* Synthesized design combining:
|
||||
* - Progress overview at top
|
||||
* - Tabbed filtering (All/Mastered/Available/Locked)
|
||||
* - Grouped accordion sections
|
||||
* - Streamlined skill cards with color-coded stripes
|
||||
* - Quick mastery checkboxes + practice buttons
|
||||
*/
|
||||
export function AllSkillsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
skills,
|
||||
currentSkillId,
|
||||
masteryStates,
|
||||
onSelectSkill,
|
||||
onToggleMastery,
|
||||
isDark = false,
|
||||
}: AllSkillsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>('all')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Calculate progress
|
||||
const masteredCount = skills.filter((s) => masteryStates.get(s.id) === true).length
|
||||
const totalCount = skills.length
|
||||
const progressPercentage = totalCount > 0 ? (masteredCount / totalCount) * 100 : 0
|
||||
|
||||
// Categorize skills
|
||||
const masteredSkills = skills.filter((s) => masteryStates.get(s.id) === true)
|
||||
const availableSkills = skills.filter((s) => {
|
||||
const isMastered = masteryStates.get(s.id) === true
|
||||
const prerequisitesMet = s.prerequisites.every(
|
||||
(prereqId) => masteryStates.get(prereqId) === true
|
||||
)
|
||||
const isAvailable = s.prerequisites.length === 0 || prerequisitesMet
|
||||
return !isMastered && isAvailable
|
||||
})
|
||||
const lockedSkills = skills.filter((s) => {
|
||||
const isMastered = masteryStates.get(s.id) === true
|
||||
const prerequisitesMet = s.prerequisites.every(
|
||||
(prereqId) => masteryStates.get(prereqId) === true
|
||||
)
|
||||
const isAvailable = s.prerequisites.length === 0 || prerequisitesMet
|
||||
return !isMastered && !isAvailable
|
||||
})
|
||||
|
||||
// Filter skills based on active tab
|
||||
const getFilteredSkills = (): SkillDefinition[] => {
|
||||
switch (activeTab) {
|
||||
case 'mastered':
|
||||
return masteredSkills
|
||||
case 'available':
|
||||
return availableSkills
|
||||
case 'locked':
|
||||
return lockedSkills
|
||||
case 'all':
|
||||
default:
|
||||
return skills
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSkills = getFilteredSkills()
|
||||
|
||||
// Helper to render a skill card
|
||||
const renderSkillCard = (skill: SkillDefinition) => {
|
||||
const isMastered = masteryStates.get(skill.id) === true
|
||||
const isCurrent = skill.id === currentSkillId
|
||||
const prerequisitesMet = skill.prerequisites.every(
|
||||
(prereqId) => masteryStates.get(prereqId) === true
|
||||
)
|
||||
const isAvailable = skill.prerequisites.length === 0 || prerequisitesMet
|
||||
const isLocked = !isAvailable
|
||||
|
||||
// Determine stripe color
|
||||
const stripeColor = isMastered
|
||||
? 'green.500'
|
||||
: isCurrent
|
||||
? 'blue.500'
|
||||
: isLocked
|
||||
? 'gray.400'
|
||||
: 'gray.300'
|
||||
|
||||
// Determine icon
|
||||
const icon = isMastered ? '✓' : isCurrent ? '⭐' : isLocked ? '🔒' : '○'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={skill.id}
|
||||
data-skill-id={skill.id}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
padding: '1rem',
|
||||
paddingLeft: '1.25rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
opacity: isLocked ? 0.7 : 1,
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: isDark ? 'gray.500' : 'gray.300',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Color-coded left stripe */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '4px',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderBottomLeftRadius: '8px',
|
||||
backgroundColor: stripeColor,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className={css({ display: 'flex', gap: '1rem', alignItems: 'flex-start' })}>
|
||||
{/* Icon + Name + Description */}
|
||||
<div className={css({ flex: 1, minWidth: 0 })}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '1.125rem', lineHeight: '1' })}>{icon}</span>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.9375rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
{skill.name}
|
||||
{isCurrent && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500',
|
||||
color: 'blue.600',
|
||||
})}
|
||||
>
|
||||
(Current)
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
lineHeight: '1.4',
|
||||
})}
|
||||
>
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Prerequisites for locked skills */}
|
||||
{isLocked && skill.prerequisites.length > 0 && (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
marginTop: '0.5rem',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
Requires:{' '}
|
||||
{skill.prerequisites
|
||||
.map((prereqId) => {
|
||||
const prereq = skills.find((s) => s.id === prereqId)
|
||||
return prereq?.name || prereqId
|
||||
})
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions: Checkbox + Practice Button */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{/* Mastery Checkbox */}
|
||||
{isAvailable && (
|
||||
<Tooltip.Root delayDuration={200}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<Checkbox.Root
|
||||
checked={isMastered}
|
||||
onCheckedChange={(checked) => onToggleMastery(skill.id, checked === true)}
|
||||
className={css({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '4px',
|
||||
border: '2px solid',
|
||||
borderColor: isMastered ? 'green.500' : isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isMastered ? 'green.500' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: isMastered ? 'green.600' : 'blue.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Checkbox.Indicator
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
✓
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.900',
|
||||
color: 'white',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.75rem',
|
||||
zIndex: 10002,
|
||||
})}
|
||||
>
|
||||
{isMastered ? 'Mark as not mastered' : 'Mark as mastered'}
|
||||
<Tooltip.Arrow className={css({ fill: isDark ? 'gray.800' : 'gray.900' })} />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{/* Practice Button */}
|
||||
{!isCurrent && isAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="select-skill"
|
||||
onClick={() => {
|
||||
onSelectSkill(skill.id)
|
||||
onClose()
|
||||
}}
|
||||
className={css({
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
whiteSpace: 'nowrap',
|
||||
_hover: {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: 'blue.50',
|
||||
color: 'blue.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Practice
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="all-skills-modal-overlay"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
padding: '1rem',
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
data-component="all-skills-modal"
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '700px',
|
||||
width: '100%',
|
||||
maxHeight: '85vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with Progress */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
})}
|
||||
>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Skills Mastery Dashboard
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{skills[0]?.operator === 'addition' ? 'Addition' : 'Subtraction'} • {masteredCount}/
|
||||
{totalCount} skills mastered
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Progress.Root
|
||||
value={progressPercentage}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '999px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<Progress.Indicator
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'green.500',
|
||||
transition: 'transform 0.3s ease',
|
||||
})}
|
||||
style={{ transform: `translateX(-${100 - progressPercentage}%)` }}
|
||||
/>
|
||||
</Progress.Root>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{Math.round(progressPercentage)}% complete
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-modal"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
marginLeft: '1rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '1',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
color: isDark ? 'gray.200' : 'gray.900',
|
||||
},
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs for Filtering */}
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tabs.Root
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as FilterTab)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
})}
|
||||
>
|
||||
<Tabs.List
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem 1.5rem 0 1.5rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{[
|
||||
{ value: 'all', label: 'All', count: skills.length },
|
||||
{ value: 'mastered', label: 'Mastered', count: masteredSkills.length },
|
||||
{ value: 'available', label: 'Available', count: availableSkills.length },
|
||||
{ value: 'locked', label: 'Locked', count: lockedSkills.length },
|
||||
].map((tab) => (
|
||||
<Tabs.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'transparent',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: 'transparent',
|
||||
_hover: {
|
||||
color: isDark ? 'gray.200' : 'gray.900',
|
||||
},
|
||||
'&[data-state=active]': {
|
||||
color: 'blue.600',
|
||||
borderColor: 'blue.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{/* Tab Content - Skills List */}
|
||||
<Tabs.Content
|
||||
value={activeTab}
|
||||
className={css({
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '1rem 1.5rem',
|
||||
minHeight: 0,
|
||||
})}
|
||||
>
|
||||
{filteredSkills.length === 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
No {activeTab} skills
|
||||
</div>
|
||||
) : (
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.75rem' })}>
|
||||
{filteredSkills.map((skill) => renderSkillCard(skill))}
|
||||
</div>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</Tooltip.Provider>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1rem 1.5rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-modal"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { css } from '../../../../../../../styled-system/css'
|
||||
import type { SkillId, SkillDefinition } from '../../skills'
|
||||
|
||||
interface CustomizeMixModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
currentSkill: SkillDefinition
|
||||
masteryStates: Map<SkillId, boolean>
|
||||
currentMixRatio: number // 0-1, where 0.25 = 25% review
|
||||
currentSelectedReviewSkills?: SkillId[]
|
||||
onApply: (mixRatio: number, selectedReviewSkills: SkillId[]) => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Customize Mix Modal
|
||||
*
|
||||
* Allows users to customize the worksheet mix:
|
||||
* - Adjust review ratio (0-100% review)
|
||||
* - Select which mastered skills to include in review
|
||||
* - Reset to defaults (75% current, all recommended review skills)
|
||||
*/
|
||||
export function CustomizeMixModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentSkill,
|
||||
masteryStates,
|
||||
currentMixRatio,
|
||||
currentSelectedReviewSkills,
|
||||
onApply,
|
||||
isDark = false,
|
||||
}: CustomizeMixModalProps) {
|
||||
const [mixRatio, setMixRatio] = useState(currentMixRatio)
|
||||
const [selectedReviewSkills, setSelectedReviewSkills] = useState<Set<SkillId>>(new Set())
|
||||
|
||||
// Get mastered skills from recommendedReview
|
||||
const masteredReviewSkills = currentSkill.recommendedReview.filter(
|
||||
(skillId) => masteryStates.get(skillId) === true
|
||||
)
|
||||
|
||||
// Initialize state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMixRatio(currentMixRatio)
|
||||
|
||||
// Get mastered review skills at the time modal opens
|
||||
const mastered = currentSkill.recommendedReview.filter(
|
||||
(skillId) => masteryStates.get(skillId) === true
|
||||
)
|
||||
|
||||
if (currentSelectedReviewSkills && currentSelectedReviewSkills.length > 0) {
|
||||
// Use user's custom selection
|
||||
setSelectedReviewSkills(new Set(currentSelectedReviewSkills))
|
||||
} else {
|
||||
// Default to all mastered review skills
|
||||
setSelectedReviewSkills(new Set(mastered))
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]) // Only run when modal opens, not when props change
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleReset = () => {
|
||||
setMixRatio(0.25) // Default 25% review
|
||||
setSelectedReviewSkills(new Set(masteredReviewSkills)) // All recommended
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(mixRatio, Array.from(selectedReviewSkills))
|
||||
onClose()
|
||||
}
|
||||
|
||||
const toggleReviewSkill = (skillId: SkillId) => {
|
||||
const newSet = new Set(selectedReviewSkills)
|
||||
if (newSet.has(skillId)) {
|
||||
newSet.delete(skillId)
|
||||
} else {
|
||||
newSet.add(skillId)
|
||||
}
|
||||
setSelectedReviewSkills(newSet)
|
||||
}
|
||||
|
||||
// Calculate problem counts based on a 20-problem worksheet
|
||||
const totalProblems = 20
|
||||
const reviewCount = Math.floor(totalProblems * mixRatio)
|
||||
const currentCount = totalProblems - reviewCount
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="customize-mix-modal-overlay"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
padding: '1rem',
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
data-component="customize-mix-modal"
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '12px',
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
Customize Worksheet Mix
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{currentSkill.name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-modal"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '1',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.100',
|
||||
color: isDark ? 'gray.200' : 'gray.900',
|
||||
},
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Mix Ratio Section */}
|
||||
<div className={css({ marginBottom: '2rem' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
Mix Ratio
|
||||
</h3>
|
||||
|
||||
{/* Visual breakdown */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
color: 'blue.700',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
Current Skill: {Math.round((1 - mixRatio) * 100)}%
|
||||
</div>
|
||||
<div
|
||||
className={css({ fontSize: '0.875rem', fontWeight: '600', color: 'blue.800' })}
|
||||
>
|
||||
{currentCount} problems
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'green.200',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
color: 'green.700',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
Review: {Math.round(mixRatio * 100)}%
|
||||
</div>
|
||||
<div
|
||||
className={css({ fontSize: '0.875rem', fontWeight: '600', color: 'green.800' })}
|
||||
>
|
||||
{reviewCount} problems
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className={css({ marginBottom: '0.5rem' })}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={Math.round(mixRatio * 100)}
|
||||
onChange={(e) => setMixRatio(Number.parseInt(e.target.value) / 100)}
|
||||
className={css({
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slider labels */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
<span>More current skill</span>
|
||||
<span>More review</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review Skills Section */}
|
||||
{masteredReviewSkills.length > 0 && (
|
||||
<div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Review Skills ({selectedReviewSkills.size} selected)
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.5rem' })}>
|
||||
{masteredReviewSkills.map((skillId) => {
|
||||
// Find skill definition to get name
|
||||
const skill = currentSkill.recommendedReview
|
||||
.map((id) => {
|
||||
// This is a bit inefficient, but works for now
|
||||
// In a real app, we'd pass skills as a prop
|
||||
return { id, name: skillId } // Placeholder
|
||||
})
|
||||
.find((s) => s.id === skillId)
|
||||
|
||||
const isSelected = selectedReviewSkills.has(skillId)
|
||||
|
||||
return (
|
||||
<label
|
||||
key={skillId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isSelected ? 'green.300' : isDark ? 'gray.600' : 'gray.200',
|
||||
backgroundColor: isSelected ? 'green.50' : isDark ? 'gray.700' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'green.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleReviewSkill(skillId)}
|
||||
className={css({
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{skillId}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{masteredReviewSkills.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
No mastered review skills available. Mark prerequisite skills as mastered to enable
|
||||
review mixing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '1rem 1.5rem',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="reset-to-default"
|
||||
onClick={handleReset}
|
||||
className={css({
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Reset to Default
|
||||
</button>
|
||||
|
||||
<div className={css({ display: 'flex', gap: '0.75rem' })}>
|
||||
<button
|
||||
type="button"
|
||||
data-action="cancel"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.700' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.600' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="apply"
|
||||
onClick={handleApply}
|
||||
className={css({
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'blue.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { css } from '../../../../../../../styled-system/css'
|
||||
import type { WorksheetFormState } from '../../types'
|
||||
import type { SkillId } from '../../skills'
|
||||
import { getSkillById, getSkillsByOperator } from '../../skills'
|
||||
import { AllSkillsModal } from './AllSkillsModal'
|
||||
import { CustomizeMixModal } from './CustomizeMixModal'
|
||||
|
||||
interface MasteryModePanelProps {
|
||||
formState: WorksheetFormState
|
||||
onChange: (updates: Partial<WorksheetFormState>) => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastery Mode Panel
|
||||
*
|
||||
* Allows users to select a skill to practice, navigate between skills,
|
||||
* and mark skills as mastered. Displays the current skill with automatic
|
||||
* configuration based on the skill's pedagogical requirements.
|
||||
*/
|
||||
export function MasteryModePanel({ formState, onChange, isDark = false }: MasteryModePanelProps) {
|
||||
const [masteryStates, setMasteryStates] = useState<Map<SkillId, boolean>>(new Map())
|
||||
const [isLoadingMastery, setIsLoadingMastery] = useState(true)
|
||||
const [isAllSkillsModalOpen, setIsAllSkillsModalOpen] = useState(false)
|
||||
const [isCustomizeMixModalOpen, setIsCustomizeMixModalOpen] = useState(false)
|
||||
|
||||
// Get current operator (default to addition, filter out 'mixed')
|
||||
const rawOperator = formState.operator ?? 'addition'
|
||||
const operator: 'addition' | 'subtraction' = rawOperator === 'mixed' ? 'addition' : rawOperator
|
||||
|
||||
// Get skills for current operator
|
||||
const availableSkills = getSkillsByOperator(operator)
|
||||
|
||||
// Get current skill ID from form state, or use first available skill
|
||||
const currentSkillId = formState.currentSkillId ?? availableSkills[0]?.id
|
||||
|
||||
// Get current skill definition
|
||||
const currentSkill = currentSkillId ? getSkillById(currentSkillId as SkillId) : availableSkills[0]
|
||||
|
||||
// Load mastery states from API
|
||||
useEffect(() => {
|
||||
async function loadMasteryStates() {
|
||||
try {
|
||||
setIsLoadingMastery(true)
|
||||
const response = await fetch(`/api/worksheets/mastery?operator=${operator}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load mastery states')
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// Convert to Map<SkillId, boolean>
|
||||
const statesMap = new Map<SkillId, boolean>()
|
||||
for (const record of data.masteryStates) {
|
||||
statesMap.set(record.skillId as SkillId, record.isMastered)
|
||||
}
|
||||
setMasteryStates(statesMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to load mastery states:', error)
|
||||
} finally {
|
||||
setIsLoadingMastery(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadMasteryStates()
|
||||
}, [operator])
|
||||
|
||||
// Apply current skill configuration to form state
|
||||
useEffect(() => {
|
||||
if (!currentSkill) return
|
||||
|
||||
console.log('[MasteryModePanel] Applying skill config:', {
|
||||
skillId: currentSkill.id,
|
||||
skillName: currentSkill.name,
|
||||
digitRange: currentSkill.digitRange,
|
||||
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: currentSkill.regroupingConfig.pAllStart,
|
||||
displayRules: currentSkill.recommendedScaffolding,
|
||||
operator: currentSkill.operator,
|
||||
})
|
||||
|
||||
// Apply skill's configuration to form state
|
||||
// This updates the preview to show problems appropriate for this skill
|
||||
onChange({
|
||||
// Keep mode as 'mastery' - displayRules will still apply conditional scaffolding
|
||||
digitRange: currentSkill.digitRange,
|
||||
pAnyStart: currentSkill.regroupingConfig.pAnyStart,
|
||||
pAllStart: currentSkill.regroupingConfig.pAllStart,
|
||||
displayRules: currentSkill.recommendedScaffolding,
|
||||
operator: currentSkill.operator,
|
||||
interpolate: false, // CRITICAL: Disable progressive difficulty in mastery mode
|
||||
} as Partial<WorksheetFormState>)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSkill?.id]) // Only run when skill ID changes, not when onChange changes
|
||||
|
||||
// Handler: Navigate to previous skill
|
||||
const handlePreviousSkill = () => {
|
||||
if (!currentSkill) return
|
||||
const currentIndex = availableSkills.findIndex((s) => s.id === currentSkill.id)
|
||||
if (currentIndex > 0) {
|
||||
const prevSkill = availableSkills[currentIndex - 1]
|
||||
onChange({ currentSkillId: prevSkill.id } as Partial<WorksheetFormState>)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler: Navigate to next skill
|
||||
const handleNextSkill = () => {
|
||||
if (!currentSkill) return
|
||||
const currentIndex = availableSkills.findIndex((s) => s.id === currentSkill.id)
|
||||
if (currentIndex < availableSkills.length - 1) {
|
||||
const nextSkill = availableSkills[currentIndex + 1]
|
||||
onChange({ currentSkillId: nextSkill.id } as Partial<WorksheetFormState>)
|
||||
}
|
||||
}
|
||||
|
||||
// Handler: Toggle mastery state
|
||||
const handleToggleMastery = async () => {
|
||||
if (!currentSkill) return
|
||||
|
||||
const currentMastery = masteryStates.get(currentSkill.id) ?? false
|
||||
const newMastery = !currentMastery
|
||||
|
||||
try {
|
||||
// Optimistically update UI
|
||||
const newStates = new Map(masteryStates)
|
||||
newStates.set(currentSkill.id, newMastery)
|
||||
setMasteryStates(newStates)
|
||||
|
||||
// Update server
|
||||
const response = await fetch('/api/worksheets/mastery', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
skillId: currentSkill.id,
|
||||
isMastered: newMastery,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update mastery state')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update mastery state:', error)
|
||||
// Revert optimistic update
|
||||
const revertedStates = new Map(masteryStates)
|
||||
revertedStates.set(currentSkill.id, currentMastery)
|
||||
setMasteryStates(revertedStates)
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentSkill) {
|
||||
return (
|
||||
<div
|
||||
data-component="mastery-mode-panel"
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<p className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>
|
||||
No skills available for {operator}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isMastered = masteryStates.get(currentSkill.id) ?? false
|
||||
|
||||
// Check if there are previous/next skills
|
||||
const currentIndex = availableSkills.findIndex((s) => s.id === currentSkill.id)
|
||||
const hasPrevious = currentIndex > 0
|
||||
const hasNext = currentIndex < availableSkills.length - 1
|
||||
|
||||
// Calculate mastery progress
|
||||
const masteredCount = availableSkills.filter((s) => masteryStates.get(s.id) === true).length
|
||||
const totalCount = availableSkills.length
|
||||
|
||||
// Check if there are any mastered review skills available
|
||||
const hasMasteredReviewSkills = currentSkill.recommendedReview.some(
|
||||
(skillId) => masteryStates.get(skillId) === true
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="mastery-mode-panel"
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={css({ marginBottom: '1rem' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
})}
|
||||
>
|
||||
Current Skill
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Skill Name and Status */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'white' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
{currentSkill.name}
|
||||
</h4>
|
||||
{isMastered && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: '1',
|
||||
})}
|
||||
title="Mastered"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{currentSkill.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation and Mastery Controls */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
marginTop: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="previous-skill"
|
||||
onClick={handlePreviousSkill}
|
||||
disabled={!hasPrevious}
|
||||
className={css({
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
opacity: hasPrevious ? 1 : 0.5,
|
||||
_hover: hasPrevious
|
||||
? {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: isDark ? 'gray.500' : 'gray.50',
|
||||
}
|
||||
: {},
|
||||
_disabled: {
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
{/* Mark as Mastered Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="toggle-mastery"
|
||||
onClick={handleToggleMastery}
|
||||
disabled={isLoadingMastery}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isMastered ? 'green.500' : isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isMastered ? 'green.50' : isDark ? 'gray.600' : 'white',
|
||||
color: isMastered ? 'green.700' : isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: isMastered ? 'green.600' : 'blue.400',
|
||||
backgroundColor: isMastered ? 'green.100' : isDark ? 'gray.500' : 'gray.50',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isLoadingMastery
|
||||
? 'Loading...'
|
||||
: isMastered
|
||||
? 'Mark as Not Mastered'
|
||||
: 'Mark as Mastered'}
|
||||
</button>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="next-skill"
|
||||
onClick={handleNextSkill}
|
||||
disabled={!hasNext}
|
||||
className={css({
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
opacity: hasNext ? 1 : 0.5,
|
||||
_hover: hasNext
|
||||
? {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: isDark ? 'gray.500' : 'gray.50',
|
||||
}
|
||||
: {},
|
||||
_disabled: {
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<div className={css({ marginTop: '1rem', display: 'flex', gap: '0.75rem' })}>
|
||||
<button
|
||||
type="button"
|
||||
data-action="view-all-skills"
|
||||
onClick={() => setIsAllSkillsModalOpen(true)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: isDark ? 'gray.500' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
View All Skills ({masteredCount}/{totalCount})
|
||||
</button>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-action="customize-mix"
|
||||
onClick={() => setIsCustomizeMixModalOpen(true)}
|
||||
disabled={!hasMasteredReviewSkills}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.500' : 'gray.300',
|
||||
backgroundColor: isDark ? 'gray.600' : 'white',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
cursor: hasMasteredReviewSkills ? 'pointer' : 'not-allowed',
|
||||
opacity: hasMasteredReviewSkills ? 1 : 0.5,
|
||||
transition: 'all 0.2s',
|
||||
_hover: hasMasteredReviewSkills
|
||||
? {
|
||||
borderColor: 'blue.400',
|
||||
backgroundColor: isDark ? 'gray.500' : 'gray.50',
|
||||
}
|
||||
: {},
|
||||
})}
|
||||
>
|
||||
Customize Mix
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
{!hasMasteredReviewSkills && (
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="top"
|
||||
className={css({
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.900',
|
||||
color: 'white',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
maxWidth: '250px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 10001,
|
||||
})}
|
||||
>
|
||||
Mark prerequisite skills as mastered to enable review mixing
|
||||
<Tooltip.Arrow className={css({ fill: isDark ? 'gray.800' : 'gray.900' })} />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
|
||||
{/* All Skills Modal */}
|
||||
<AllSkillsModal
|
||||
isOpen={isAllSkillsModalOpen}
|
||||
onClose={() => setIsAllSkillsModalOpen(false)}
|
||||
skills={availableSkills}
|
||||
currentSkillId={currentSkill.id}
|
||||
masteryStates={masteryStates}
|
||||
onSelectSkill={(skillId) => {
|
||||
onChange({ currentSkillId: skillId } as Partial<WorksheetFormState>)
|
||||
}}
|
||||
onToggleMastery={async (skillId, isMastered) => {
|
||||
// Optimistically update UI
|
||||
const newStates = new Map(masteryStates)
|
||||
newStates.set(skillId, isMastered)
|
||||
setMasteryStates(newStates)
|
||||
|
||||
try {
|
||||
// Update server
|
||||
const response = await fetch('/api/worksheets/mastery', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
skillId,
|
||||
isMastered,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update mastery state')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update mastery state:', error)
|
||||
// Revert optimistic update
|
||||
const revertedStates = new Map(masteryStates)
|
||||
revertedStates.set(skillId, !isMastered)
|
||||
setMasteryStates(revertedStates)
|
||||
}
|
||||
}}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Customize Mix Modal */}
|
||||
<CustomizeMixModal
|
||||
isOpen={isCustomizeMixModalOpen}
|
||||
onClose={() => setIsCustomizeMixModalOpen(false)}
|
||||
currentSkill={currentSkill}
|
||||
masteryStates={masteryStates}
|
||||
currentMixRatio={formState.reviewMixRatio ?? 0.25}
|
||||
currentSelectedReviewSkills={formState.selectedReviewSkills as SkillId[] | undefined}
|
||||
onApply={(mixRatio, selectedReviewSkills) => {
|
||||
onChange({
|
||||
reviewMixRatio: mixRatio,
|
||||
selectedReviewSkills,
|
||||
} as Partial<WorksheetFormState>)
|
||||
}}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { css } from '../../../../../../../styled-system/css'
|
||||
import type { WorksheetFormState } from '../../types'
|
||||
import {
|
||||
SINGLE_CARRY_PATH,
|
||||
getStepFromSliderValue,
|
||||
getSliderValueFromStep,
|
||||
findNearestStep,
|
||||
getStepById,
|
||||
} from '../../progressionPath'
|
||||
|
||||
interface ProgressionModePanelProps {
|
||||
formState: WorksheetFormState
|
||||
onChange: (updates: Partial<WorksheetFormState>) => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
interface MasteryState {
|
||||
stepId: string
|
||||
isMastered: boolean
|
||||
attempts: number
|
||||
correctCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Progression Mode Panel
|
||||
*
|
||||
* Slider-based UI that follows a curated learning path through 3D space:
|
||||
* - Digit count (1-5 digits)
|
||||
* - Regrouping difficulty (0-100%)
|
||||
* - Scaffolding level (full → minimal)
|
||||
*
|
||||
* Key feature: Scaffolding cycles as complexity increases (ten-frames return!)
|
||||
*/
|
||||
export function ProgressionModePanel({
|
||||
formState,
|
||||
onChange,
|
||||
isDark = false,
|
||||
}: ProgressionModePanelProps) {
|
||||
// Get current step from formState or default to first step
|
||||
const currentStepId = formState.currentStepId ?? SINGLE_CARRY_PATH[0].id
|
||||
const currentStep = getStepById(currentStepId, SINGLE_CARRY_PATH) ?? SINGLE_CARRY_PATH[0]
|
||||
|
||||
// Derive slider value from current step
|
||||
const sliderValue = getSliderValueFromStep(currentStep.stepNumber, SINGLE_CARRY_PATH.length)
|
||||
|
||||
// Track whether advanced controls are expanded
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
// Track mastery states for all steps
|
||||
const [masteryStates, setMasteryStates] = useState<Map<string, MasteryState>>(new Map())
|
||||
const [isLoadingMastery, setIsLoadingMastery] = useState(true)
|
||||
|
||||
// Load mastery data from API
|
||||
useEffect(() => {
|
||||
async function loadMasteryStates() {
|
||||
try {
|
||||
setIsLoadingMastery(true)
|
||||
const response = await fetch('/api/worksheets/mastery?operator=addition')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load mastery states')
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
// Convert to Map<stepId, MasteryState>
|
||||
// The API returns data with skill IDs, we'll use them as step IDs for now
|
||||
const statesMap = new Map<string, MasteryState>()
|
||||
for (const record of data.masteryStates) {
|
||||
statesMap.set(record.skillId, {
|
||||
stepId: record.skillId,
|
||||
isMastered: record.isMastered,
|
||||
attempts: record.attempts ?? 0,
|
||||
correctCount: record.correctCount ?? 0,
|
||||
})
|
||||
}
|
||||
setMasteryStates(statesMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to load mastery states:', error)
|
||||
} finally {
|
||||
setIsLoadingMastery(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadMasteryStates()
|
||||
}, [])
|
||||
|
||||
// Apply current step's configuration to form state when step changes
|
||||
useEffect(() => {
|
||||
console.log('[ProgressionModePanel] Applying step config:', {
|
||||
stepId: currentStep.id,
|
||||
stepName: currentStep.name,
|
||||
stepNumber: currentStep.stepNumber,
|
||||
config: currentStep.config,
|
||||
})
|
||||
|
||||
onChange({
|
||||
currentStepId: currentStep.id,
|
||||
...currentStep.config,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStep.id]) // Only run when step ID changes
|
||||
|
||||
// Handler: Slider value changes
|
||||
const handleSliderChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = Number(event.target.value)
|
||||
const newStep = getStepFromSliderValue(newValue, SINGLE_CARRY_PATH)
|
||||
|
||||
console.log('[ProgressionModePanel] Slider changed:', {
|
||||
sliderValue: newValue,
|
||||
newStepNumber: newStep.stepNumber,
|
||||
newStepId: newStep.id,
|
||||
})
|
||||
|
||||
// Apply new step's config
|
||||
onChange({
|
||||
currentStepId: newStep.id,
|
||||
...newStep.config,
|
||||
})
|
||||
}
|
||||
|
||||
// Handler: Manual digit count change
|
||||
const handleDigitChange = (digits: number) => {
|
||||
const updatedConfig = {
|
||||
...formState,
|
||||
digitRange: { min: digits, max: digits },
|
||||
}
|
||||
|
||||
// Find nearest step matching new config
|
||||
const nearestStep = findNearestStep(updatedConfig, SINGLE_CARRY_PATH)
|
||||
|
||||
console.log('[ProgressionModePanel] Manual digit change:', {
|
||||
digits,
|
||||
nearestStepId: nearestStep.id,
|
||||
nearestStepNumber: nearestStep.stepNumber,
|
||||
})
|
||||
|
||||
// Apply nearest step's full config (not just digit range)
|
||||
onChange({
|
||||
currentStepId: nearestStep.id,
|
||||
...nearestStep.config,
|
||||
})
|
||||
}
|
||||
|
||||
// Handler: Manual scaffolding change
|
||||
const handleScaffoldingChange = (tenFrames: 'whenRegrouping' | 'never') => {
|
||||
// Build complete displayRules with the new tenFrames value
|
||||
const displayRules = currentStep.config.displayRules
|
||||
? { ...currentStep.config.displayRules, tenFrames }
|
||||
: undefined
|
||||
|
||||
const updatedConfig = {
|
||||
...formState,
|
||||
displayRules,
|
||||
}
|
||||
|
||||
// Find nearest step matching new config
|
||||
const nearestStep = findNearestStep(updatedConfig, SINGLE_CARRY_PATH)
|
||||
|
||||
console.log('[ProgressionModePanel] Manual scaffolding change:', {
|
||||
tenFrames,
|
||||
nearestStepId: nearestStep.id,
|
||||
nearestStepNumber: nearestStep.stepNumber,
|
||||
})
|
||||
|
||||
// Apply nearest step's full config
|
||||
onChange({
|
||||
currentStepId: nearestStep.id,
|
||||
...nearestStep.config,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine scaffolding level description
|
||||
const hasFullScaffolding = currentStep.config.displayRules?.tenFrames === 'whenRegrouping'
|
||||
const scaffoldingDesc = hasFullScaffolding
|
||||
? 'Full scaffolding (ten-frames shown)'
|
||||
: 'Independent practice (no ten-frames)'
|
||||
|
||||
// Get next step info
|
||||
const nextStep = currentStep.nextStepId
|
||||
? getStepById(currentStep.nextStepId, SINGLE_CARRY_PATH)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="progression-mode-panel"
|
||||
className={css({
|
||||
padding: '1.5rem',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={css({ marginBottom: '1.5rem' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
marginBottom: '0.5rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
})}
|
||||
>
|
||||
Difficulty Progression
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div
|
||||
data-element="slider-container"
|
||||
className={css({
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Easier
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sliderValue}
|
||||
onChange={handleSliderChange}
|
||||
data-element="difficulty-slider"
|
||||
className={css({
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Harder
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status */}
|
||||
<div
|
||||
data-element="current-status"
|
||||
className={css({
|
||||
marginBottom: '1.5rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Currently practicing:
|
||||
</h4>
|
||||
<ul
|
||||
className={css({
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<li
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: isDark ? 'blue.400' : 'blue.600' })}>•</span>
|
||||
{currentStep.config.digitRange?.min}-digit problems
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: isDark ? 'blue.400' : 'blue.600' })}>•</span>
|
||||
{currentStep.name}
|
||||
</li>
|
||||
<li
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: isDark ? 'blue.400' : 'blue.600' })}>•</span>
|
||||
{scaffoldingDesc}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Progress Dots */}
|
||||
<div
|
||||
data-element="progress-dots"
|
||||
className={css({
|
||||
marginBottom: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Progress:
|
||||
</span>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{SINGLE_CARRY_PATH.map((step) => {
|
||||
const isMastered = masteryStates.get(step.id)?.isMastered ?? false
|
||||
const isCurrent = step.id === currentStep.id
|
||||
|
||||
return (
|
||||
<span
|
||||
key={step.id}
|
||||
data-step={step.stepNumber}
|
||||
data-current={isCurrent}
|
||||
data-mastered={isMastered}
|
||||
className={css({
|
||||
width: '0.75rem',
|
||||
height: '0.75rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isMastered
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600' // Mastered = green
|
||||
: step.stepNumber <= currentStep.stepNumber
|
||||
? isDark
|
||||
? 'blue.400'
|
||||
: 'blue.600' // Current/past = blue
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300', // Future = gray
|
||||
border: isCurrent ? '2px solid' : 'none',
|
||||
borderColor: isCurrent ? (isDark ? 'yellow.400' : 'yellow.600') : undefined,
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
title={`${step.name}${isMastered ? ' ✓ Mastered' : ''}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Step {currentStep.stepNumber + 1} of {SINGLE_CARRY_PATH.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Next Milestone */}
|
||||
{nextStep && (
|
||||
<div
|
||||
data-element="next-milestone"
|
||||
className={css({
|
||||
marginBottom: '1.5rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'blue.50',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'blue.200',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'blue.300' : 'blue.700',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
Next milestone:
|
||||
</h4>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
→ {nextStep.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Controls Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
data-action="toggle-advanced-controls"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
color: isDark ? 'blue.400' : 'blue.600',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{showAdvanced ? 'Hide' : 'Show'} Advanced Controls
|
||||
<span className={css({ fontSize: '0.75rem' })}>{showAdvanced ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* Advanced Controls (Collapsible) */}
|
||||
{showAdvanced && (
|
||||
<div
|
||||
data-element="advanced-controls"
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
{/* Digit Count */}
|
||||
<div className={css({ marginBottom: '1.5rem' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Digit Count:
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => handleDigitChange(d)}
|
||||
data-setting="digit-count"
|
||||
data-value={d}
|
||||
data-selected={formState.digitRange?.min === d}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
color:
|
||||
formState.digitRange?.min === d ? 'white' : isDark ? 'gray.300' : 'gray.700',
|
||||
backgroundColor:
|
||||
formState.digitRange?.min === d
|
||||
? isDark
|
||||
? 'blue.600'
|
||||
: 'blue.500'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'gray.100',
|
||||
border: '1px solid',
|
||||
borderColor:
|
||||
formState.digitRange?.min === d
|
||||
? isDark
|
||||
? 'blue.500'
|
||||
: 'blue.400'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
formState.digitRange?.min === d
|
||||
? isDark
|
||||
? 'blue.500'
|
||||
: 'blue.400'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scaffolding Level */}
|
||||
<div className={css({ marginBottom: '1rem' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
Scaffolding Level:
|
||||
</label>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.5rem' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="scaffolding"
|
||||
checked={formState.displayRules?.tenFrames === 'whenRegrouping'}
|
||||
onChange={() => handleScaffoldingChange('whenRegrouping')}
|
||||
data-setting="scaffolding"
|
||||
data-value="full"
|
||||
className={css({ cursor: 'pointer' })}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
Full (ten-frames, carry boxes, colors)
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="scaffolding"
|
||||
checked={formState.displayRules?.tenFrames === 'never'}
|
||||
onChange={() => handleScaffoldingChange('never')}
|
||||
data-setting="scaffolding"
|
||||
data-value="minimal"
|
||||
className={css({ cursor: 'pointer' })}
|
||||
/>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
Minimal (no ten-frames)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div
|
||||
className={css({
|
||||
padding: '0.75rem',
|
||||
backgroundColor: isDark ? 'yellow.900' : 'yellow.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'yellow.700' : 'yellow.300',
|
||||
borderRadius: '4px',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
color: isDark ? 'yellow.200' : 'yellow.800',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
⚠ Manual changes will move you to the nearest step on the progression path
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
286
apps/web/src/app/create/worksheets/addition/masteryLogic.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Mastery Mode Logic
|
||||
*
|
||||
* Functions for review selection, problem distribution, and mastery worksheet generation.
|
||||
* Used by the worksheet generator to mix current skill practice with review problems.
|
||||
*/
|
||||
|
||||
import type { WorksheetMastery } from '@/db/schema'
|
||||
import { SKILL_DEFINITIONS, type SkillDefinition, type SkillId, getSkillById } from './skills'
|
||||
import type { WorksheetConfig } from './types'
|
||||
|
||||
/**
|
||||
* Mastery state map: skill ID -> mastery record
|
||||
*/
|
||||
export type MasteryStateMap = Map<SkillId, WorksheetMastery>
|
||||
|
||||
/**
|
||||
* Review selection result
|
||||
*/
|
||||
export interface ReviewSelection {
|
||||
/** Skills to include in review */
|
||||
skills: SkillDefinition[]
|
||||
/** Number of problems per skill */
|
||||
problemsPerSkill: Map<SkillId, number>
|
||||
/** Total review problems */
|
||||
totalProblems: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Mastery worksheet mix
|
||||
*/
|
||||
export interface MasteryWorksheetMix {
|
||||
/** Current skill being practiced */
|
||||
currentSkill: SkillDefinition
|
||||
/** Number of current skill problems */
|
||||
currentSkillProblems: number
|
||||
/** Review selection */
|
||||
review: ReviewSelection
|
||||
/** Total problems in worksheet */
|
||||
totalProblems: number
|
||||
/** Mix ratio (0-1, where 0.25 = 25% review) */
|
||||
mixRatio: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get review skills for a given current skill, filtered by mastery state
|
||||
*
|
||||
* @param currentSkill - The skill being practiced
|
||||
* @param masteryStates - Map of skill IDs to mastery records
|
||||
* @param selectedReviewSkills - Optional manual override of which review skills to include
|
||||
* @returns Array of review skills (only mastered skills from recommendedReview)
|
||||
*/
|
||||
export function getReviewSkills(
|
||||
currentSkill: SkillDefinition,
|
||||
masteryStates: MasteryStateMap,
|
||||
selectedReviewSkills?: SkillId[]
|
||||
): SkillDefinition[] {
|
||||
// If user manually selected review skills, use those (filtered by mastery)
|
||||
if (selectedReviewSkills && selectedReviewSkills.length > 0) {
|
||||
return selectedReviewSkills
|
||||
.filter((skillId) => {
|
||||
const masteryState = masteryStates.get(skillId)
|
||||
return masteryState?.isMastered === true
|
||||
})
|
||||
.map((skillId) => getSkillById(skillId))
|
||||
.filter((skill): skill is SkillDefinition => skill !== undefined)
|
||||
}
|
||||
|
||||
// Otherwise, use recommended review skills (filtered by mastery)
|
||||
return currentSkill.recommendedReview
|
||||
.filter((skillId) => {
|
||||
const masteryState = masteryStates.get(skillId)
|
||||
return masteryState?.isMastered === true
|
||||
})
|
||||
.map((skillId) => getSkillById(skillId))
|
||||
.filter((skill): skill is SkillDefinition => skill !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute review problem count across review skills
|
||||
*
|
||||
* @param reviewSkills - Array of review skills
|
||||
* @param totalReviewProblems - Total number of review problems to distribute
|
||||
* @returns Map of skill ID to problem count
|
||||
*/
|
||||
export function distributeReviewProblems(
|
||||
reviewSkills: SkillDefinition[],
|
||||
totalReviewProblems: number
|
||||
): Map<SkillId, number> {
|
||||
const distribution = new Map<SkillId, number>()
|
||||
|
||||
if (reviewSkills.length === 0) {
|
||||
return distribution
|
||||
}
|
||||
|
||||
// Simple strategy: distribute evenly, with remainder going to first skills
|
||||
const baseCount = Math.floor(totalReviewProblems / reviewSkills.length)
|
||||
const remainder = totalReviewProblems % reviewSkills.length
|
||||
|
||||
reviewSkills.forEach((skill, index) => {
|
||||
const count = baseCount + (index < remainder ? 1 : 0)
|
||||
if (count > 0) {
|
||||
distribution.set(skill.id, count)
|
||||
}
|
||||
})
|
||||
|
||||
return distribution
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mastery worksheet mix
|
||||
*
|
||||
* @param currentSkillId - The skill being practiced
|
||||
* @param masteryStates - Map of skill IDs to mastery records
|
||||
* @param totalProblems - Total problems in worksheet
|
||||
* @param mixRatio - Review ratio (0-1, where 0.25 = 25% review)
|
||||
* @param selectedReviewSkills - Optional manual override of which review skills to include
|
||||
* @returns Mastery worksheet mix breakdown
|
||||
*/
|
||||
export function calculateMasteryMix(
|
||||
currentSkillId: SkillId,
|
||||
masteryStates: MasteryStateMap,
|
||||
totalProblems: number,
|
||||
mixRatio: number = 0.25,
|
||||
selectedReviewSkills?: SkillId[]
|
||||
): MasteryWorksheetMix {
|
||||
const currentSkill = getSkillById(currentSkillId)
|
||||
if (!currentSkill) {
|
||||
throw new Error(`Skill not found: ${currentSkillId}`)
|
||||
}
|
||||
|
||||
// Clamp mix ratio to 0-1
|
||||
const clampedRatio = Math.max(0, Math.min(1, mixRatio))
|
||||
|
||||
// Calculate problem counts
|
||||
const reviewProblemCount = Math.floor(totalProblems * clampedRatio)
|
||||
const currentProblemCount = totalProblems - reviewProblemCount
|
||||
|
||||
// Get review skills
|
||||
const reviewSkills = getReviewSkills(currentSkill, masteryStates, selectedReviewSkills)
|
||||
|
||||
// Distribute review problems
|
||||
const problemsPerSkill = distributeReviewProblems(reviewSkills, reviewProblemCount)
|
||||
|
||||
return {
|
||||
currentSkill,
|
||||
currentSkillProblems: currentProblemCount,
|
||||
review: {
|
||||
skills: reviewSkills,
|
||||
problemsPerSkill,
|
||||
totalProblems: reviewProblemCount,
|
||||
},
|
||||
totalProblems,
|
||||
mixRatio: clampedRatio,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert skill definition to WorksheetConfig for problem generation
|
||||
*
|
||||
* This is the bridge between mastery mode and smart mode.
|
||||
* Each skill's configuration (digitRange, regrouping, scaffolding) maps directly
|
||||
* to a Smart Mode configuration.
|
||||
*
|
||||
* @param skill - Skill definition
|
||||
* @param problemCount - Number of problems to generate for this skill
|
||||
* @returns WorksheetConfig for problem generation
|
||||
*/
|
||||
export function skillToConfig(
|
||||
skill: SkillDefinition,
|
||||
problemCount: number
|
||||
): Partial<WorksheetConfig> {
|
||||
return {
|
||||
version: 4,
|
||||
mode: 'smart',
|
||||
|
||||
// Digit range from skill
|
||||
digitRange: skill.digitRange,
|
||||
|
||||
// Regrouping configuration from skill
|
||||
pAnyStart: skill.regroupingConfig.pAnyStart,
|
||||
pAllStart: skill.regroupingConfig.pAllStart,
|
||||
|
||||
// Scaffolding from skill
|
||||
displayRules: skill.recommendedScaffolding,
|
||||
|
||||
// Problem count
|
||||
problemsPerPage: problemCount,
|
||||
|
||||
// Operator from skill
|
||||
operator: skill.operator,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate worksheet configuration for mastery mode
|
||||
*
|
||||
* This function takes a mastery mix and converts it into a configuration
|
||||
* that can be used to generate problems. It's the final step before problem generation.
|
||||
*
|
||||
* @param mix - Mastery worksheet mix
|
||||
* @returns WorksheetConfig for the entire worksheet
|
||||
*/
|
||||
export function generateMasteryWorksheetConfig(
|
||||
mix: MasteryWorksheetMix
|
||||
): Partial<WorksheetConfig> & {
|
||||
_masteryMix?: {
|
||||
currentSkillId: SkillId
|
||||
currentSkillProblems: number
|
||||
reviewProblems: number
|
||||
reviewSkills: SkillId[]
|
||||
reviewProblemCounts: Record<string, number>
|
||||
mixRatio: number
|
||||
}
|
||||
} {
|
||||
// Start with current skill config
|
||||
const config = skillToConfig(mix.currentSkill, mix.totalProblems)
|
||||
|
||||
// Add mastery-specific metadata for UI observability
|
||||
// (This is stored separately and not persisted to the schema)
|
||||
return {
|
||||
...config,
|
||||
|
||||
// Store review breakdown for observability
|
||||
// (This can be used by the UI to show what's in the mix)
|
||||
_masteryMix: {
|
||||
currentSkillId: mix.currentSkill.id,
|
||||
currentSkillProblems: mix.currentSkillProblems,
|
||||
reviewProblems: mix.review.totalProblems,
|
||||
reviewSkills: mix.review.skills.map((s) => s.id),
|
||||
reviewProblemCounts: Object.fromEntries(mix.review.problemsPerSkill),
|
||||
mixRatio: mix.mixRatio,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get mastery state for a skill (or create default if not exists)
|
||||
*
|
||||
* @param skillId - Skill ID
|
||||
* @param masteryStates - Map of skill IDs to mastery records
|
||||
* @returns Mastery state (or default state if not found)
|
||||
*/
|
||||
export function getMasteryState(
|
||||
skillId: SkillId,
|
||||
masteryStates: MasteryStateMap
|
||||
): WorksheetMastery | { isMastered: false } {
|
||||
return masteryStates.get(skillId) || { isMastered: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a skill's prerequisites are met
|
||||
*
|
||||
* @param skill - Skill definition
|
||||
* @param masteryStates - Map of skill IDs to mastery records
|
||||
* @returns True if all prerequisites are mastered
|
||||
*/
|
||||
export function arePrerequisitesMet(
|
||||
skill: SkillDefinition,
|
||||
masteryStates: MasteryStateMap
|
||||
): boolean {
|
||||
return skill.prerequisites.every((prereqId) => {
|
||||
const state = getMasteryState(prereqId, masteryStates)
|
||||
return state.isMastered === true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get next available skill (first skill with unmet prerequisites or not mastered)
|
||||
*
|
||||
* @param operator - "addition" or "subtraction"
|
||||
* @param masteryStates - Map of skill IDs to mastery records
|
||||
* @returns Next skill to practice, or undefined if all mastered
|
||||
*/
|
||||
export function getNextAvailableSkill(
|
||||
operator: 'addition' | 'subtraction',
|
||||
masteryStates: MasteryStateMap
|
||||
): SkillDefinition | undefined {
|
||||
const skills = SKILL_DEFINITIONS.filter((s) => s.operator === operator)
|
||||
|
||||
// Find first skill that is not mastered
|
||||
return skills.find((skill) => {
|
||||
const state = getMasteryState(skill.id, masteryStates)
|
||||
return state.isMastered !== true
|
||||
})
|
||||
}
|
||||
348
apps/web/src/app/create/worksheets/addition/progressionPath.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
// Progression path system for guided worksheet configuration
|
||||
// Maps a 1D slider to discrete steps through 3D space (digit count × regrouping × scaffolding)
|
||||
|
||||
import type { WorksheetFormState } from './types'
|
||||
|
||||
/**
|
||||
* A single step in the mastery progression path
|
||||
* Each step represents a complete worksheet configuration
|
||||
*/
|
||||
export interface ProgressionStep {
|
||||
// Unique ID for this step
|
||||
id: string
|
||||
|
||||
// Position in progression (0-based)
|
||||
stepNumber: number
|
||||
|
||||
// Which technique is being practiced
|
||||
technique:
|
||||
| 'basic-addition'
|
||||
| 'single-carry'
|
||||
| 'multi-carry'
|
||||
| 'basic-subtraction'
|
||||
| 'single-borrow'
|
||||
| 'multi-borrow'
|
||||
|
||||
// Human-readable description
|
||||
name: string
|
||||
description: string
|
||||
|
||||
// Complete worksheet configuration for this step
|
||||
// Uses worksheet config v4 format - no new version!
|
||||
config: Partial<WorksheetFormState>
|
||||
|
||||
// Mastery tracking
|
||||
masteryThreshold: number // e.g., 0.85 = 85% accuracy required
|
||||
minimumAttempts: number // e.g., 15 problems minimum
|
||||
|
||||
// Navigation
|
||||
nextStepId: string | null
|
||||
previousStepId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete progression path for single-carry technique
|
||||
* This path demonstrates the scaffolding cycle pattern:
|
||||
* - Increase complexity (digit count) → reintroduce scaffolding (ten-frames)
|
||||
* - Fade scaffolding (remove ten-frames)
|
||||
* - Repeat
|
||||
*/
|
||||
export const SINGLE_CARRY_PATH: ProgressionStep[] = [
|
||||
// ========================================================================
|
||||
// PHASE 1: Single-digit carrying
|
||||
// ========================================================================
|
||||
|
||||
// Step 0: 1-digit with full scaffolding
|
||||
{
|
||||
id: 'single-carry-1d-full',
|
||||
stepNumber: 0,
|
||||
technique: 'single-carry',
|
||||
name: 'Single-digit carrying (with support)',
|
||||
description: 'Learn carrying with single-digit problems and visual support',
|
||||
config: {
|
||||
digitRange: { min: 1, max: 1 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0, // 100% regrouping
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // ← FULL SCAFFOLDING
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false, // No progressive difficulty in mastery mode
|
||||
},
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-1d-minimal',
|
||||
previousStepId: null,
|
||||
},
|
||||
|
||||
// Step 1: 1-digit with minimal scaffolding
|
||||
{
|
||||
id: 'single-carry-1d-minimal',
|
||||
stepNumber: 1,
|
||||
technique: 'single-carry',
|
||||
name: 'Single-digit carrying (independent)',
|
||||
description: 'Practice carrying without visual aids',
|
||||
config: {
|
||||
digitRange: { min: 1, max: 1 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // ← SCAFFOLDING FADED
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-2d-full',
|
||||
previousStepId: 'single-carry-1d-full',
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 2: Two-digit carrying (ones place only)
|
||||
// ========================================================================
|
||||
|
||||
// Step 2: 2-digit with full scaffolding (SCAFFOLDING RETURNS!)
|
||||
{
|
||||
id: 'single-carry-2d-full',
|
||||
stepNumber: 2,
|
||||
technique: 'single-carry',
|
||||
name: 'Two-digit carrying (with support)',
|
||||
description: 'Apply carrying to two-digit problems with visual support',
|
||||
config: {
|
||||
digitRange: { min: 2, max: 2 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0, // Ones place only
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // ← SCAFFOLDING RETURNS for new complexity!
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-2d-minimal',
|
||||
previousStepId: 'single-carry-1d-minimal',
|
||||
},
|
||||
|
||||
// Step 3: 2-digit with minimal scaffolding
|
||||
{
|
||||
id: 'single-carry-2d-minimal',
|
||||
stepNumber: 3,
|
||||
technique: 'single-carry',
|
||||
name: 'Two-digit carrying (independent)',
|
||||
description: 'Practice two-digit carrying without visual aids',
|
||||
config: {
|
||||
digitRange: { min: 2, max: 2 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // ← SCAFFOLDING FADED
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-3d-full',
|
||||
previousStepId: 'single-carry-2d-full',
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 3: Three-digit carrying (ones place only)
|
||||
// ========================================================================
|
||||
|
||||
// Step 4: 3-digit with full scaffolding (SCAFFOLDING RETURNS AGAIN!)
|
||||
{
|
||||
id: 'single-carry-3d-full',
|
||||
stepNumber: 4,
|
||||
technique: 'single-carry',
|
||||
name: 'Three-digit carrying (with support)',
|
||||
description: 'Apply carrying to three-digit problems with visual support',
|
||||
config: {
|
||||
digitRange: { min: 3, max: 3 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0, // Ones place only
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // ← SCAFFOLDING RETURNS for 3-digit!
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: 'single-carry-3d-minimal',
|
||||
previousStepId: 'single-carry-2d-minimal',
|
||||
},
|
||||
|
||||
// Step 5: 3-digit with minimal scaffolding
|
||||
{
|
||||
id: 'single-carry-3d-minimal',
|
||||
stepNumber: 5,
|
||||
technique: 'single-carry',
|
||||
name: 'Three-digit carrying (independent)',
|
||||
description: 'Practice three-digit carrying without visual aids',
|
||||
config: {
|
||||
digitRange: { min: 3, max: 3 },
|
||||
operator: 'addition',
|
||||
pAnyStart: 1.0,
|
||||
pAllStart: 0,
|
||||
displayRules: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // ← SCAFFOLDING FADED
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
interpolate: false,
|
||||
},
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 20,
|
||||
nextStepId: null, // End of single-carry path (for now)
|
||||
previousStepId: 'single-carry-3d-full',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Map slider value (0-100) to progression step
|
||||
* @param sliderValue - Value from 0 to 100
|
||||
* @param path - Progression path to use
|
||||
* @returns The step corresponding to this slider position
|
||||
*/
|
||||
export function getStepFromSliderValue(
|
||||
sliderValue: number,
|
||||
path: ProgressionStep[]
|
||||
): ProgressionStep {
|
||||
// Clamp slider value
|
||||
const clampedValue = Math.max(0, Math.min(100, sliderValue))
|
||||
|
||||
// Map to step index
|
||||
const stepIndex = Math.round((clampedValue / 100) * (path.length - 1))
|
||||
|
||||
return path[stepIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map progression step to slider value (0-100)
|
||||
* @param stepNumber - Step number (0-based)
|
||||
* @param pathLength - Total number of steps in path
|
||||
* @returns Slider value from 0 to 100
|
||||
*/
|
||||
export function getSliderValueFromStep(stepNumber: number, pathLength: number): number {
|
||||
if (pathLength <= 1) return 0
|
||||
return (stepNumber / (pathLength - 1)) * 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Find nearest step in path matching given config
|
||||
* Useful when user manually changes settings - finds where they are on the path
|
||||
* @param config - Current worksheet configuration
|
||||
* @param path - Progression path to search
|
||||
* @returns The step that best matches the config
|
||||
*/
|
||||
export function findNearestStep(
|
||||
config: Partial<WorksheetFormState>,
|
||||
path: ProgressionStep[]
|
||||
): ProgressionStep {
|
||||
let bestMatch = path[0]
|
||||
let bestScore = -Infinity
|
||||
|
||||
for (const step of path) {
|
||||
let score = 0
|
||||
|
||||
// Match digit range (most important - 100 points)
|
||||
if (
|
||||
step.config.digitRange?.min === config.digitRange?.min &&
|
||||
step.config.digitRange?.max === config.digitRange?.max
|
||||
) {
|
||||
score += 100
|
||||
}
|
||||
|
||||
// Match regrouping config (50 points each)
|
||||
if (step.config.pAnyStart === config.pAnyStart) score += 50
|
||||
if (step.config.pAllStart === config.pAllStart) score += 50
|
||||
|
||||
// Match scaffolding - ten-frames (30 points)
|
||||
if (step.config.displayRules?.tenFrames === config.displayRules?.tenFrames) {
|
||||
score += 30
|
||||
}
|
||||
|
||||
// Match operator (20 points)
|
||||
if (step.config.operator === config.operator) score += 20
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = step
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config exactly matches a step
|
||||
* @param config - Current worksheet configuration
|
||||
* @param step - Step to compare against
|
||||
* @returns True if config matches step configuration
|
||||
*/
|
||||
export function configMatchesStep(
|
||||
config: Partial<WorksheetFormState>,
|
||||
step: ProgressionStep
|
||||
): boolean {
|
||||
return (
|
||||
config.digitRange?.min === step.config.digitRange?.min &&
|
||||
config.digitRange?.max === step.config.digitRange?.max &&
|
||||
config.pAnyStart === step.config.pAnyStart &&
|
||||
config.pAllStart === step.config.pAllStart &&
|
||||
config.displayRules?.tenFrames === step.config.displayRules?.tenFrames &&
|
||||
config.operator === step.config.operator
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step by ID
|
||||
* @param stepId - Step ID to find
|
||||
* @param path - Progression path to search
|
||||
* @returns The step with matching ID, or undefined
|
||||
*/
|
||||
export function getStepById(stepId: string, path: ProgressionStep[]): ProgressionStep | undefined {
|
||||
return path.find((step) => step.id === stepId)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Migration mapping from old skill IDs to new progression step IDs
|
||||
// This allows existing mastery data to work with the new progression system
|
||||
|
||||
import type { SkillId } from './skills'
|
||||
|
||||
/**
|
||||
* Maps old skill IDs to new progression step IDs
|
||||
*
|
||||
* Strategy:
|
||||
* - Old skills with ten-frames → map to "full" scaffolding steps
|
||||
* - Old skills without ten-frames → map to "minimal" scaffolding steps
|
||||
* - Skills that don't have a direct equivalent → map to closest step
|
||||
*/
|
||||
export const SKILL_TO_STEP_MIGRATION: Record<SkillId, string> = {
|
||||
// ============================================================================
|
||||
// ADDITION SKILLS → SINGLE_CARRY_PATH steps
|
||||
// ============================================================================
|
||||
|
||||
// Single-digit addition
|
||||
'sd-no-regroup': 'single-carry-1d-full', // Basic addition, map to first step
|
||||
'sd-simple-regroup': 'single-carry-1d-full', // Has ten-frames → full scaffolding
|
||||
|
||||
// Two-digit addition
|
||||
'td-no-regroup': 'single-carry-2d-full', // No regrouping, but map to 2-digit entry point
|
||||
'td-ones-regroup': 'single-carry-2d-full', // Ones place regrouping → 2-digit full
|
||||
'td-mixed-regroup': 'single-carry-2d-minimal', // Mixed regrouping, more advanced → minimal
|
||||
'td-full-regroup': 'single-carry-2d-minimal', // Full regrouping → minimal scaffolding
|
||||
|
||||
// Three-digit addition
|
||||
'3d-no-regroup': 'single-carry-3d-full', // No regrouping, but map to 3-digit entry
|
||||
'3d-simple-regroup': 'single-carry-3d-full', // Simple regrouping → full scaffolding
|
||||
'3d-full-regroup': 'single-carry-3d-minimal', // Full regrouping → minimal scaffolding
|
||||
|
||||
// Four/five-digit addition (no direct equivalent, map to end of path)
|
||||
'4d-mastery': 'single-carry-3d-minimal', // Advanced, map to final step
|
||||
'5d-mastery': 'single-carry-3d-minimal', // Advanced, map to final step
|
||||
|
||||
// ============================================================================
|
||||
// SUBTRACTION SKILLS → Future borrowing paths
|
||||
// ============================================================================
|
||||
// For now, map to addition equivalents (will create SINGLE_BORROW_PATH later)
|
||||
|
||||
// Single-digit subtraction
|
||||
'sd-sub-no-borrow': 'single-carry-1d-full', // Map to equivalent complexity
|
||||
'sd-sub-borrow': 'single-carry-1d-full',
|
||||
|
||||
// Two-digit subtraction
|
||||
'td-sub-no-borrow': 'single-carry-2d-full',
|
||||
'td-sub-ones-borrow': 'single-carry-2d-full',
|
||||
'td-sub-mixed-borrow': 'single-carry-2d-minimal',
|
||||
'td-sub-full-borrow': 'single-carry-2d-minimal',
|
||||
|
||||
// Three-digit subtraction
|
||||
'3d-sub-simple': 'single-carry-3d-full',
|
||||
'3d-sub-complex': 'single-carry-3d-minimal',
|
||||
|
||||
// Four/five-digit subtraction
|
||||
'4d-sub-mastery': 'single-carry-3d-minimal',
|
||||
'5d-sub-mastery': 'single-carry-3d-minimal',
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate old skill ID to new step ID
|
||||
* @param skillId - Old skill ID from skills.ts
|
||||
* @returns New step ID from progressionPath.ts
|
||||
*/
|
||||
export function migrateSkillToStep(skillId: SkillId): string {
|
||||
return SKILL_TO_STEP_MIGRATION[skillId] ?? 'single-carry-1d-full'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skill ID exists in the migration mapping
|
||||
*/
|
||||
export function isSkillMigrated(skillId: SkillId): boolean {
|
||||
return skillId in SKILL_TO_STEP_MIGRATION
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all old skill IDs that map to a given step ID
|
||||
* Useful for displaying which legacy skills contributed to a step's mastery
|
||||
*/
|
||||
export function getSkillsForStep(stepId: string): SkillId[] {
|
||||
return Object.entries(SKILL_TO_STEP_MIGRATION)
|
||||
.filter(([_, targetStepId]) => targetStepId === stepId)
|
||||
.map(([skillId]) => skillId as SkillId)
|
||||
}
|
||||
674
apps/web/src/app/create/worksheets/addition/skills.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
// Skill definitions for mastery mode
|
||||
// Each skill represents a pedagogical milestone in learning addition/subtraction
|
||||
|
||||
import type { DisplayRules } from './displayRules'
|
||||
|
||||
/**
|
||||
* Skill IDs follow naming convention:
|
||||
* - Prefix: sd (single-digit), td (two-digit), 3d/4d/5d (multi-digit)
|
||||
* - Operation: addition skills have descriptive names, subtraction uses "sub" prefix
|
||||
* - Complexity: no-regroup, simple-regroup, ones-regroup, mixed-regroup, full-regroup
|
||||
*/
|
||||
export type SkillId =
|
||||
// Single-digit addition
|
||||
| 'sd-no-regroup'
|
||||
| 'sd-simple-regroup'
|
||||
// Two-digit addition
|
||||
| 'td-no-regroup'
|
||||
| 'td-ones-regroup'
|
||||
| 'td-mixed-regroup'
|
||||
| 'td-full-regroup'
|
||||
// Three-digit addition
|
||||
| '3d-no-regroup'
|
||||
| '3d-simple-regroup'
|
||||
| '3d-full-regroup'
|
||||
// Four/five-digit addition
|
||||
| '4d-mastery'
|
||||
| '5d-mastery'
|
||||
// Single-digit subtraction
|
||||
| 'sd-sub-no-borrow'
|
||||
| 'sd-sub-borrow'
|
||||
// Two-digit subtraction
|
||||
| 'td-sub-no-borrow'
|
||||
| 'td-sub-ones-borrow'
|
||||
| 'td-sub-mixed-borrow'
|
||||
| 'td-sub-full-borrow'
|
||||
// Three-digit subtraction
|
||||
| '3d-sub-simple'
|
||||
| '3d-sub-complex'
|
||||
// Four/five-digit subtraction
|
||||
| '4d-sub-mastery'
|
||||
| '5d-sub-mastery'
|
||||
|
||||
export interface SkillDefinition {
|
||||
id: SkillId
|
||||
name: string
|
||||
description: string
|
||||
operator: 'addition' | 'subtraction'
|
||||
|
||||
// Problem generation constraints
|
||||
digitRange: { min: number; max: number }
|
||||
regroupingConfig: {
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
}
|
||||
|
||||
// Pedagogical settings
|
||||
recommendedScaffolding: DisplayRules
|
||||
recommendedProblemCount: number
|
||||
|
||||
// Mastery validation (for future grading)
|
||||
masteryThreshold: number // 0.0-1.0 (e.g., 0.85 = 85% accuracy)
|
||||
minimumAttempts: number // Min problems to qualify for mastery
|
||||
|
||||
// Dependency graph
|
||||
prerequisites: SkillId[] // Skills that must be mastered first
|
||||
recommendedReview: SkillId[] // 1-2 skills to include in review mix
|
||||
}
|
||||
|
||||
/**
|
||||
* All skill definitions (21 total: 11 addition, 10 subtraction)
|
||||
* Organized in pedagogical progression order
|
||||
*/
|
||||
export const SKILL_DEFINITIONS: SkillDefinition[] = [
|
||||
// ============================================================================
|
||||
// ADDITION SKILLS (11 total)
|
||||
// ============================================================================
|
||||
|
||||
// Single-Digit Addition (2 skills)
|
||||
{
|
||||
id: 'sd-no-regroup',
|
||||
name: 'Single-digit without regrouping',
|
||||
description: 'Simple single-digit addition like 3+5, 2+4',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'always',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // Not needed for simple problems
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 20,
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
prerequisites: [],
|
||||
recommendedReview: [],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sd-simple-regroup',
|
||||
name: 'Single-digit with regrouping',
|
||||
description: 'Single-digit addition with carrying like 7+8, 9+6',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // Help visualize making ten
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 20,
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
prerequisites: ['sd-no-regroup'],
|
||||
recommendedReview: ['sd-no-regroup'],
|
||||
},
|
||||
|
||||
// Two-Digit Addition (4 skills)
|
||||
{
|
||||
id: 'td-no-regroup',
|
||||
name: 'Two-digit without regrouping',
|
||||
description: 'Two-digit addition without carrying like 23+45, 31+28',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'always', // Show structure even when not needed
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['sd-simple-regroup'],
|
||||
recommendedReview: ['sd-simple-regroup'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'td-ones-regroup',
|
||||
name: 'Two-digit with ones place regrouping',
|
||||
description: 'Two-digit addition with carrying in ones place like 38+27, 49+15',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.5, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['td-no-regroup'],
|
||||
recommendedReview: ['td-no-regroup'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'td-mixed-regroup',
|
||||
name: 'Two-digit with mixed regrouping',
|
||||
description: 'Two-digit addition with varied regrouping like 67+58, 84+73',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.7, pAllStart: 0.2 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'whenMultipleRegroups',
|
||||
placeValueColors: 'whenRegrouping',
|
||||
tenFrames: 'whenRegrouping',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['td-ones-regroup'],
|
||||
recommendedReview: ['td-no-regroup', 'td-ones-regroup'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'td-full-regroup',
|
||||
name: 'Two-digit with frequent regrouping',
|
||||
description: 'Two-digit addition with high regrouping frequency like 88+99, 76+67',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.9, pAllStart: 0.5 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenMultipleRegroups',
|
||||
answerBoxes: 'whenMultipleRegroups',
|
||||
placeValueColors: 'whenMultipleRegroups',
|
||||
tenFrames: 'whenRegrouping',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['td-mixed-regroup'],
|
||||
recommendedReview: ['td-ones-regroup', 'td-mixed-regroup'],
|
||||
},
|
||||
|
||||
// Three-Digit Addition (3 skills)
|
||||
{
|
||||
id: '3d-no-regroup',
|
||||
name: 'Three-digit without regrouping',
|
||||
description: 'Three-digit addition without carrying like 234+451, 123+456',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ['td-full-regroup'],
|
||||
recommendedReview: ['td-mixed-regroup', 'td-full-regroup'],
|
||||
},
|
||||
|
||||
{
|
||||
id: '3d-simple-regroup',
|
||||
name: 'Three-digit with occasional regrouping',
|
||||
description: 'Three-digit addition with some carrying like 367+258, 484+273',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0.5, pAllStart: 0.1 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'whenMultipleRegroups',
|
||||
placeValueColors: 'whenRegrouping',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ['3d-no-regroup'],
|
||||
recommendedReview: ['td-full-regroup', '3d-no-regroup'],
|
||||
},
|
||||
|
||||
{
|
||||
id: '3d-full-regroup',
|
||||
name: 'Three-digit with frequent regrouping',
|
||||
description: 'Three-digit addition with high regrouping like 888+999, 767+676',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0.9, pAllStart: 0.6 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenMultipleRegroups',
|
||||
answerBoxes: 'never',
|
||||
placeValueColors: 'when3PlusDigits',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ['3d-simple-regroup'],
|
||||
recommendedReview: ['3d-no-regroup', '3d-simple-regroup'],
|
||||
},
|
||||
|
||||
// Four/Five-Digit Addition (2 skills)
|
||||
{
|
||||
id: '4d-mastery',
|
||||
name: 'Four-digit mastery',
|
||||
description: 'Four-digit addition with varied regrouping like 3847+2956',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 4, max: 4 },
|
||||
regroupingConfig: { pAnyStart: 0.8, pAllStart: 0.4 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'never',
|
||||
placeValueColors: 'when3PlusDigits',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 10,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 10,
|
||||
prerequisites: ['3d-full-regroup'],
|
||||
recommendedReview: ['3d-simple-regroup', '3d-full-regroup'],
|
||||
},
|
||||
|
||||
{
|
||||
id: '5d-mastery',
|
||||
name: 'Five-digit mastery',
|
||||
description: 'Five-digit addition with varied regrouping like 38472+29563',
|
||||
operator: 'addition',
|
||||
digitRange: { min: 5, max: 5 },
|
||||
regroupingConfig: { pAnyStart: 0.85, pAllStart: 0.5 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'never',
|
||||
placeValueColors: 'when3PlusDigits',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 10,
|
||||
masteryThreshold: 0.75,
|
||||
minimumAttempts: 10,
|
||||
prerequisites: ['4d-mastery'],
|
||||
recommendedReview: ['3d-full-regroup', '4d-mastery'],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SUBTRACTION SKILLS (10 total)
|
||||
// ============================================================================
|
||||
|
||||
// Single-Digit Subtraction (2 skills)
|
||||
{
|
||||
id: 'sd-sub-no-borrow',
|
||||
name: 'Single-digit without borrowing',
|
||||
description: 'Simple subtraction like 8-3, 9-4',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'always',
|
||||
borrowingHints: 'always',
|
||||
},
|
||||
recommendedProblemCount: 20,
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
prerequisites: [],
|
||||
recommendedReview: [],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sd-sub-borrow',
|
||||
name: 'Single-digit with borrowing',
|
||||
description: 'Subtraction with borrowing like 13-7, 15-8',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 1, max: 1 },
|
||||
regroupingConfig: { pAnyStart: 1.0, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'whenRegrouping',
|
||||
},
|
||||
recommendedProblemCount: 20,
|
||||
masteryThreshold: 0.9,
|
||||
minimumAttempts: 20,
|
||||
prerequisites: ['sd-sub-no-borrow'],
|
||||
recommendedReview: ['sd-sub-no-borrow'],
|
||||
},
|
||||
|
||||
// Two-Digit Subtraction (4 skills)
|
||||
{
|
||||
id: 'td-sub-no-borrow',
|
||||
name: 'Two-digit without borrowing',
|
||||
description: 'Two-digit subtraction without borrowing like 68-43',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'always',
|
||||
borrowingHints: 'always',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['sd-sub-borrow'],
|
||||
recommendedReview: ['sd-sub-borrow'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'td-sub-ones-borrow',
|
||||
name: 'Two-digit with ones place borrowing',
|
||||
description: 'Two-digit subtraction with borrowing in ones like 52-27',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.5, pAllStart: 0 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'whenRegrouping',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['td-sub-no-borrow'],
|
||||
recommendedReview: ['td-sub-no-borrow'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'td-sub-mixed-borrow',
|
||||
name: 'Two-digit with mixed borrowing',
|
||||
description: 'Two-digit subtraction with varied borrowing like 73-48',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.7, pAllStart: 0.2 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'whenMultipleRegroups',
|
||||
placeValueColors: 'whenRegrouping',
|
||||
tenFrames: 'whenRegrouping',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'whenMultipleRegroups',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.85,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['td-sub-ones-borrow'],
|
||||
recommendedReview: ['td-sub-no-borrow', 'td-sub-ones-borrow'],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'td-sub-full-borrow',
|
||||
name: 'Two-digit with frequent borrowing',
|
||||
description: 'Two-digit subtraction with high borrowing frequency like 91-78',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 2, max: 2 },
|
||||
regroupingConfig: { pAnyStart: 0.9, pAllStart: 0.5 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'whenMultipleRegroups',
|
||||
placeValueColors: 'whenMultipleRegroups',
|
||||
tenFrames: 'whenRegrouping',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 15,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 15,
|
||||
prerequisites: ['td-sub-mixed-borrow'],
|
||||
recommendedReview: ['td-sub-ones-borrow', 'td-sub-mixed-borrow'],
|
||||
},
|
||||
|
||||
// Three-Digit Subtraction (2 skills - simplified from addition)
|
||||
{
|
||||
id: '3d-sub-simple',
|
||||
name: 'Three-digit with occasional borrowing',
|
||||
description: 'Three-digit subtraction with some borrowing like 567-238',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0.5, pAllStart: 0.1 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'whenMultipleRegroups',
|
||||
placeValueColors: 'whenRegrouping',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ['td-sub-full-borrow'],
|
||||
recommendedReview: ['td-sub-mixed-borrow', 'td-sub-full-borrow'],
|
||||
},
|
||||
|
||||
{
|
||||
id: '3d-sub-complex',
|
||||
name: 'Three-digit with frequent borrowing',
|
||||
description: 'Three-digit subtraction with high borrowing like 801-567',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 3, max: 3 },
|
||||
regroupingConfig: { pAnyStart: 0.9, pAllStart: 0.6 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'never',
|
||||
placeValueColors: 'when3PlusDigits',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 12,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 12,
|
||||
prerequisites: ['3d-sub-simple'],
|
||||
recommendedReview: ['td-sub-full-borrow', '3d-sub-simple'],
|
||||
},
|
||||
|
||||
// Four/Five-Digit Subtraction (2 skills)
|
||||
{
|
||||
id: '4d-sub-mastery',
|
||||
name: 'Four-digit subtraction mastery',
|
||||
description: 'Four-digit subtraction with varied borrowing like 5847-2956',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 4, max: 4 },
|
||||
regroupingConfig: { pAnyStart: 0.8, pAllStart: 0.4 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'never',
|
||||
placeValueColors: 'when3PlusDigits',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 10,
|
||||
masteryThreshold: 0.8,
|
||||
minimumAttempts: 10,
|
||||
prerequisites: ['3d-sub-complex'],
|
||||
recommendedReview: ['3d-sub-simple', '3d-sub-complex'],
|
||||
},
|
||||
|
||||
{
|
||||
id: '5d-sub-mastery',
|
||||
name: 'Five-digit subtraction mastery',
|
||||
description: 'Five-digit subtraction with varied borrowing like 58472-29563',
|
||||
operator: 'subtraction',
|
||||
digitRange: { min: 5, max: 5 },
|
||||
regroupingConfig: { pAnyStart: 0.85, pAllStart: 0.5 },
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'never',
|
||||
placeValueColors: 'when3PlusDigits',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedProblemCount: 10,
|
||||
masteryThreshold: 0.75,
|
||||
minimumAttempts: 10,
|
||||
prerequisites: ['4d-sub-mastery'],
|
||||
recommendedReview: ['3d-sub-complex', '4d-sub-mastery'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Helper: Get skill definition by ID
|
||||
*/
|
||||
export function getSkillById(id: SkillId): SkillDefinition | undefined {
|
||||
return SKILL_DEFINITIONS.find((skill) => skill.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get all skills for a given operator
|
||||
*/
|
||||
export function getSkillsByOperator(operator: 'addition' | 'subtraction'): SkillDefinition[] {
|
||||
return SKILL_DEFINITIONS.filter((skill) => skill.operator === operator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Find next skill in progression (null if at end or prerequisites not met)
|
||||
*/
|
||||
export function findNextSkill(
|
||||
currentSkillId: SkillId,
|
||||
masteryStates: Map<SkillId, boolean>,
|
||||
operator: 'addition' | 'subtraction'
|
||||
): SkillDefinition | null {
|
||||
const skills = getSkillsByOperator(operator)
|
||||
const currentIndex = skills.findIndex((s) => s.id === currentSkillId)
|
||||
|
||||
if (currentIndex === -1 || currentIndex === skills.length - 1) {
|
||||
return null // Not found or at end
|
||||
}
|
||||
|
||||
const nextSkill = skills[currentIndex + 1]
|
||||
|
||||
// Check if prerequisites are met
|
||||
const prereqsMet = nextSkill.prerequisites.every(
|
||||
(prereqId) => masteryStates.get(prereqId) === true
|
||||
)
|
||||
|
||||
return prereqsMet ? nextSkill : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Find previous skill in progression
|
||||
*/
|
||||
export function findPreviousSkill(
|
||||
currentSkillId: SkillId,
|
||||
operator: 'addition' | 'subtraction'
|
||||
): SkillDefinition | null {
|
||||
const skills = getSkillsByOperator(operator)
|
||||
const currentIndex = skills.findIndex((s) => s.id === currentSkillId)
|
||||
|
||||
if (currentIndex <= 0) {
|
||||
return null // Not found or at start
|
||||
}
|
||||
|
||||
return skills[currentIndex - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get the first umastered skill with prerequisites met
|
||||
*/
|
||||
export function findCurrentSkill(
|
||||
masteryStates: Map<SkillId, boolean>,
|
||||
operator: 'addition' | 'subtraction'
|
||||
): SkillDefinition {
|
||||
const skills = getSkillsByOperator(operator)
|
||||
|
||||
for (const skill of skills) {
|
||||
// Check if already mastered
|
||||
if (masteryStates.get(skill.id) === true) continue
|
||||
|
||||
// Check if prerequisites are met
|
||||
const prereqsMet = skill.prerequisites.every((prereqId) => masteryStates.get(prereqId) === true)
|
||||
|
||||
if (prereqsMet) {
|
||||
return skill // First non-mastered skill with prerequisites met
|
||||
}
|
||||
}
|
||||
|
||||
// All skills mastered! Return the last skill
|
||||
return skills[skills.length - 1]
|
||||
}
|
||||
191
apps/web/src/app/create/worksheets/addition/techniques.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// Core mathematical techniques for mastery progression
|
||||
// Techniques are actual skills (carrying, borrowing), not complexity levels
|
||||
|
||||
import type { DisplayRules } from './displayRules'
|
||||
|
||||
/**
|
||||
* Technique IDs
|
||||
* These represent actual mathematical procedures/algorithms to learn
|
||||
*/
|
||||
export type TechniqueId =
|
||||
// Addition Techniques
|
||||
| 'basic-addition' // No carrying required
|
||||
| 'single-carry' // Carrying in one place value
|
||||
| 'multi-carry' // Carrying across multiple place values
|
||||
|
||||
// Subtraction Techniques
|
||||
| 'basic-subtraction' // No borrowing required
|
||||
| 'single-borrow' // Borrowing from one place value
|
||||
| 'multi-borrow' // Borrowing across multiple place values
|
||||
|
||||
/**
|
||||
* A mathematical technique that can be learned and mastered
|
||||
*/
|
||||
export interface Technique {
|
||||
id: TechniqueId
|
||||
name: string
|
||||
description: string
|
||||
operator: 'addition' | 'subtraction'
|
||||
|
||||
// What OTHER techniques must be mastered first?
|
||||
prerequisites: TechniqueId[]
|
||||
|
||||
// Recommended scaffolding for this technique (baseline)
|
||||
// Complexity levels can adjust these
|
||||
recommendedScaffolding: Partial<DisplayRules>
|
||||
|
||||
// Which techniques should be reviewed when practicing this one?
|
||||
recommendedReview: TechniqueId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* All techniques in the learning progression
|
||||
*/
|
||||
export const TECHNIQUES: Record<TechniqueId, Technique> = {
|
||||
// ============================================================================
|
||||
// ADDITION TECHNIQUES
|
||||
// ============================================================================
|
||||
|
||||
'basic-addition': {
|
||||
id: 'basic-addition',
|
||||
name: 'Basic Addition',
|
||||
description: 'Simple addition without carrying (no regrouping)',
|
||||
operator: 'addition',
|
||||
prerequisites: [],
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never', // No carrying, so no carry boxes needed
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // Ten-frames for regrouping visualization
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedReview: [],
|
||||
},
|
||||
|
||||
'single-carry': {
|
||||
id: 'single-carry',
|
||||
name: 'Single-place Carrying',
|
||||
description: 'Addition with carrying (regrouping) in one place value',
|
||||
operator: 'addition',
|
||||
prerequisites: ['basic-addition'],
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenRegrouping', // Show carry boxes when carrying happens
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // Help visualize making ten
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedReview: ['basic-addition'],
|
||||
},
|
||||
|
||||
'multi-carry': {
|
||||
id: 'multi-carry',
|
||||
name: 'Multi-place Carrying',
|
||||
description: 'Addition with carrying across multiple place values',
|
||||
operator: 'addition',
|
||||
prerequisites: ['single-carry'],
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'whenRegrouping',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never', // Less scaffolding for advanced students
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedReview: ['single-carry', 'basic-addition'],
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SUBTRACTION TECHNIQUES
|
||||
// ============================================================================
|
||||
|
||||
'basic-subtraction': {
|
||||
id: 'basic-subtraction',
|
||||
name: 'Basic Subtraction',
|
||||
description: 'Simple subtraction without borrowing (no regrouping)',
|
||||
operator: 'subtraction',
|
||||
prerequisites: ['basic-addition'], // Addition first
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'never', // No borrowing, so no notation needed
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedReview: ['basic-addition'],
|
||||
},
|
||||
|
||||
'single-borrow': {
|
||||
id: 'single-borrow',
|
||||
name: 'Single-place Borrowing',
|
||||
description: 'Subtraction with borrowing (regrouping) from one place value',
|
||||
operator: 'subtraction',
|
||||
prerequisites: ['basic-subtraction', 'single-carry'], // Need to understand regrouping concept
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'whenRegrouping', // Help visualize breaking apart ten
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping', // Show scratch work for borrowing
|
||||
borrowingHints: 'never', // Start without hints, can add later
|
||||
},
|
||||
recommendedReview: ['basic-subtraction', 'single-carry'],
|
||||
},
|
||||
|
||||
'multi-borrow': {
|
||||
id: 'multi-borrow',
|
||||
name: 'Multi-place Borrowing',
|
||||
description: 'Subtraction with borrowing across multiple place values',
|
||||
operator: 'subtraction',
|
||||
prerequisites: ['single-borrow'],
|
||||
recommendedScaffolding: {
|
||||
carryBoxes: 'never',
|
||||
answerBoxes: 'always',
|
||||
placeValueColors: 'always',
|
||||
tenFrames: 'never',
|
||||
problemNumbers: 'always',
|
||||
cellBorders: 'always',
|
||||
borrowNotation: 'whenRegrouping',
|
||||
borrowingHints: 'never',
|
||||
},
|
||||
recommendedReview: ['single-borrow', 'basic-subtraction'],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Get technique by ID
|
||||
*/
|
||||
export function getTechnique(id: TechniqueId): Technique {
|
||||
return TECHNIQUES[id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all techniques for an operator
|
||||
*/
|
||||
export function getTechniquesByOperator(operator: 'addition' | 'subtraction'): Technique[] {
|
||||
return Object.values(TECHNIQUES).filter((t) => t.operator === operator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if technique prerequisites are met
|
||||
*/
|
||||
export function arePrerequisitesMet(
|
||||
techniqueId: TechniqueId,
|
||||
masteredTechniques: Set<TechniqueId>
|
||||
): boolean {
|
||||
const technique = TECHNIQUES[techniqueId]
|
||||
return technique.prerequisites.every((prereq) => masteredTechniques.has(prereq))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { css } from '../../../../../styled-system/css'
|
||||
interface AttemptResult {
|
||||
attemptId: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
errorMessage: string | null
|
||||
totalProblems: number | null
|
||||
correctCount: number | null
|
||||
accuracy: number | null
|
||||
@@ -217,8 +218,7 @@ export default function AttemptResultsPage({ params }: { params: { attemptId: st
|
||||
Grading Failed
|
||||
</h2>
|
||||
<p className={css({ color: 'yellow.600', mb: 4 })}>
|
||||
The image might be too blurry, not a math worksheet, or missing problems. Please try
|
||||
uploading a different image.
|
||||
{result.errorMessage || 'The image might be too blurry, not a math worksheet, or missing problems. Please try uploading a different image.'}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
@@ -47,6 +47,9 @@ export const worksheetAttempts = sqliteTable(
|
||||
/** When grading was completed */
|
||||
gradedAt: integer('graded_at', { mode: 'timestamp' }),
|
||||
|
||||
/** Error message if grading failed */
|
||||
errorMessage: text('error_message'),
|
||||
|
||||
/** Total number of problems detected/graded */
|
||||
totalProblems: integer('total_problems'),
|
||||
|
||||
|
||||
@@ -172,9 +172,9 @@ async function callGPT5Vision(imageDataUrl: string, prompt: string): Promise<Gra
|
||||
reasoning: {
|
||||
effort: 'high', // Use deep thinking for analysis
|
||||
},
|
||||
verbosity: 'medium', // Balanced output
|
||||
max_output_tokens: 4000, // Enough for 20 problems + analysis
|
||||
text: {
|
||||
verbosity: 'medium', // Balanced output
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'worksheet_grading',
|
||||
|
||||
@@ -145,10 +145,17 @@ export async function processWorksheetAttempt(attemptId: string) {
|
||||
} catch (error) {
|
||||
console.error('Grading failed:', error)
|
||||
|
||||
// Mark as failed
|
||||
// Extract error message
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during grading'
|
||||
|
||||
// Mark as failed with error message
|
||||
await db
|
||||
.update(worksheetAttempts)
|
||||
.set({ gradingStatus: 'failed', updatedAt: new Date() })
|
||||
.set({
|
||||
gradingStatus: 'failed',
|
||||
errorMessage,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(worksheetAttempts.id, attemptId))
|
||||
|
||||
throw error
|
||||
|
||||