diff --git a/apps/web/src/app/api/curriculum/[playerId]/skills/route.ts b/apps/web/src/app/api/curriculum/[playerId]/skills/route.ts index 23a18fe7..fd776d50 100644 --- a/apps/web/src/app/api/curriculum/[playerId]/skills/route.ts +++ b/apps/web/src/app/api/curriculum/[playerId]/skills/route.ts @@ -1,11 +1,12 @@ /** - * API route for recording skill attempts + * API route for skill mastery operations * * POST /api/curriculum/[playerId]/skills - Record a skill attempt + * PUT /api/curriculum/[playerId]/skills - Set mastered skills (manual override) */ import { NextResponse } from 'next/server' -import { recordSkillAttempt } from '@/lib/curriculum/progress-manager' +import { recordSkillAttempt, setMasteredSkills } from '@/lib/curriculum/progress-manager' interface RouteParams { params: Promise<{ playerId: string }> @@ -41,3 +42,36 @@ export async function POST(request: Request, { params }: RouteParams) { return NextResponse.json({ error: 'Failed to record skill attempt' }, { status: 500 }) } } + +/** + * PUT - Set which skills are mastered (teacher manual override) + * Body: { masteredSkillIds: string[] } + */ +export async function PUT(request: Request, { params }: RouteParams) { + try { + const { playerId } = await params + + if (!playerId) { + return NextResponse.json({ error: 'Player ID required' }, { status: 400 }) + } + + const body = await request.json() + const { masteredSkillIds } = body + + if (!Array.isArray(masteredSkillIds)) { + return NextResponse.json({ error: 'masteredSkillIds must be an array' }, { status: 400 }) + } + + // Validate that all items are strings + if (!masteredSkillIds.every((id) => typeof id === 'string')) { + return NextResponse.json({ error: 'All skill IDs must be strings' }, { status: 400 }) + } + + const result = await setMasteredSkills(playerId, masteredSkillIds) + + return NextResponse.json(result) + } catch (error) { + console.error('Error setting mastered skills:', error) + return NextResponse.json({ error: 'Failed to set mastered skills' }, { status: 500 }) + } +} diff --git a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx index 2158f9b3..53e57299 100644 --- a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx +++ b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx @@ -34,6 +34,8 @@ interface ConfigureClientProps { focusDescription: string /** Average seconds per problem based on student's history */ avgSecondsPerProblem: number + /** List of mastered skill IDs that will be used in problem generation */ + masteredSkillIds: string[] } /** @@ -83,6 +85,36 @@ function getPartTypeColors( } } +/** + * Skill category display names + */ +const SKILL_CATEGORY_NAMES: Record = { + basic: 'Basic', + fiveComplements: '5-Complements', + fiveComplementsSub: '5-Complements (Sub)', + tenComplements: '10-Complements', + tenComplementsSub: '10-Complements (Sub)', +} + +/** + * Group and format skill IDs for display + */ +function groupSkillsByCategory(skillIds: string[]): Map { + const grouped = new Map() + + for (const skillId of skillIds) { + const [category, ...rest] = skillId.split('.') + const skillName = rest.join('.') + + if (!grouped.has(category)) { + grouped.set(category, []) + } + grouped.get(category)!.push(skillName) + } + + return grouped +} + /** * Calculate estimated session breakdown based on duration */ @@ -144,6 +176,7 @@ export function ConfigureClient({ existingPlan, focusDescription, avgSecondsPerProblem, + masteredSkillIds, }: ConfigureClientProps) { const router = useRouter() const queryClient = useQueryClient() @@ -595,6 +628,91 @@ export function ConfigureClient({ + + {/* Mastered Skills Summary (collapsible) */} +
+ + Mastered Skills ({masteredSkillIds.length}) + + +
+ {masteredSkillIds.length === 0 ? ( +

+ No skills marked as mastered yet. Go to Dashboard to set skills. +

+ ) : ( +
+ {Array.from(groupSkillsByCategory(masteredSkillIds)).map( + ([category, skills]) => ( +
+
+ {SKILL_CATEGORY_NAMES[category] || category} +
+
+ {skills.map((skill) => ( + + {skill} + + ))} +
+
+ ) + )} +
+ )} +
+
diff --git a/apps/web/src/app/practice/[studentId]/configure/page.tsx b/apps/web/src/app/practice/[studentId]/configure/page.tsx index f8d7de4a..f80013af 100644 --- a/apps/web/src/app/practice/[studentId]/configure/page.tsx +++ b/apps/web/src/app/practice/[studentId]/configure/page.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation' import { getPhaseDisplayInfo } from '@/lib/curriculum/definitions' import { getActiveSessionPlan, + getAllSkillMastery, getPlayer, getPlayerCurriculum, getRecentSessions, @@ -31,12 +32,13 @@ interface ConfigurePageProps { export default async function ConfigurePage({ params }: ConfigurePageProps) { const { studentId } = await params - // Fetch player, curriculum, sessions, and active session in parallel - const [player, activeSession, curriculum, recentSessions] = await Promise.all([ + // Fetch player, curriculum, sessions, skills, and active session in parallel + const [player, activeSession, curriculum, recentSessions, skills] = await Promise.all([ getPlayer(studentId), getActiveSessionPlan(studentId), getPlayerCurriculum(studentId), getRecentSessions(studentId, 10), + getAllSkillMastery(studentId), ]) // 404 if player doesn't exist @@ -63,6 +65,9 @@ export default async function ConfigurePage({ params }: ConfigurePageProps) { avgSecondsPerProblem = Math.round(weightedSum / totalProblems / 1000) } + // Get mastered skills for display + const masteredSkills = skills.filter((s) => s.masteryLevel === 'mastered').map((s) => s.skillId) + return ( ) } diff --git a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx index fbee59c7..afd5aabc 100644 --- a/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx +++ b/apps/web/src/app/practice/[studentId]/dashboard/DashboardClient.tsx @@ -158,11 +158,25 @@ export function DashboardClient({ }, []) // Handle saving manual skill selections - const handleSaveManualSkills = useCallback(async (masteredSkillIds: string[]): Promise => { - // TODO: Save skills to curriculum via API - console.log('Manual skills saved:', masteredSkillIds) - setShowManualSkillModal(false) - }, []) + const handleSaveManualSkills = useCallback( + async (masteredSkillIds: string[]): Promise => { + const response = await fetch(`/api/curriculum/${studentId}/skills`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masteredSkillIds }), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to save skills') + } + + // Reload the page to show updated skills + router.refresh() + setShowManualSkillModal(false) + }, + [studentId, router] + ) // Handle opening offline session form const handleRecordOfflinePractice = useCallback(() => { @@ -246,6 +260,9 @@ export function DashboardClient({ open={showManualSkillModal} onClose={() => setShowManualSkillModal(false)} onSave={handleSaveManualSkills} + currentMasteredSkills={skills + .filter((s) => s.masteryLevel === 'mastered') + .map((s) => s.skillId)} /> {/* Offline Session Form Modal */} diff --git a/apps/web/src/components/practice/ManualSkillSelector.tsx b/apps/web/src/components/practice/ManualSkillSelector.tsx index 7a593012..91129750 100644 --- a/apps/web/src/components/practice/ManualSkillSelector.tsx +++ b/apps/web/src/components/practice/ManualSkillSelector.tsx @@ -2,7 +2,7 @@ import * as Accordion from '@radix-ui/react-accordion' import * as Dialog from '@radix-ui/react-dialog' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTheme } from '@/contexts/ThemeContext' import { css } from '../../../styled-system/css' @@ -192,6 +192,13 @@ export function ManualSkillSelector({ const [isSaving, setIsSaving] = useState(false) const [expandedCategories, setExpandedCategories] = useState([]) + // Sync selected skills when modal opens with new data + useEffect(() => { + if (open) { + setSelectedSkills(new Set(currentMasteredSkills)) + } + }, [open, currentMasteredSkills]) + const handlePresetChange = (presetKey: string) => { if (presetKey === '') { // Clear all diff --git a/apps/web/src/components/practice/SessionSummary.tsx b/apps/web/src/components/practice/SessionSummary.tsx index bca6fefc..105060bc 100644 --- a/apps/web/src/components/practice/SessionSummary.tsx +++ b/apps/web/src/components/practice/SessionSummary.tsx @@ -1,7 +1,7 @@ 'use client' import { useTheme } from '@/contexts/ThemeContext' -import type { SessionPlan, SlotResult } from '@/db/schema/session-plans' +import type { SessionPart, SessionPlan, SlotResult } from '@/db/schema/session-plans' import { css } from '../../../styled-system/css' interface SessionSummaryProps { @@ -401,9 +401,89 @@ export function SessionSummary({ )} - {/* Problem review (collapsed by default) */} + {/* Problem review - answered problems with results (collapsed by default) */} + {results.length > 0 && ( +
+ + Review Answered Problems ({totalProblems}) + + +
+ {results.map((result, index) => ( +
+ {result.isCorrect ? '✓' : '✗'} + + {formatProblem(result.problem.terms)} = {result.problem.answer} + + {!result.isCorrect && ( + + {result.studentAnswer} + + )} +
+ ))} +
+
+ )} + + {/* View all planned problems - shows full problem set by part (for teachers) */}
- Review All Problems ({totalProblems}) + View All Planned Problems ({getTotalPlannedProblems(plan.parts)})
- {results.map((result, index) => ( -
- {result.isCorrect ? '✓' : '✗'} - ( +
+

- {formatProblem(result.problem.terms)} = {result.problem.answer} - - {!result.isCorrect && ( + Part {part.partNumber}: {getPartTypeName(part.type)} - {result.studentAnswer} + ({part.slots.length} problems) - )} +

+ +
+ {part.slots.map((slot) => { + // Find if this slot has been answered + const result = results.find( + (r) => r.partNumber === part.partNumber && r.slotIndex === slot.index + ) + const problem = result?.problem ?? slot.problem + + return ( +
+ {/* Status indicator */} + + {result ? (result.isCorrect ? '✓' : '✗') : '○'} + + + {/* Problem number */} + + #{slot.index + 1} + + + {/* Problem content */} + {problem ? ( + + {formatProblem(problem.terms)} = {problem.answer} + + ) : ( + + (not yet generated) + + )} + + {/* Purpose tag */} + + {slot.purpose} + + + {/* Wrong answer (if incorrect) */} + {result && !result.isCorrect && ( + + {result.studentAnswer} + + )} +
+ ) + })} +
))}
@@ -592,4 +796,21 @@ function getPerformanceMessage(accuracy: number): string { return "Keep practicing! You'll get better with each session!" } +function getTotalPlannedProblems(parts: SessionPart[]): number { + return parts.reduce((sum, part) => sum + part.slots.length, 0) +} + +function getPartTypeName(type: SessionPart['type']): string { + switch (type) { + case 'abacus': + return 'Use Abacus' + case 'visualization': + return 'Mental Math (Visualization)' + case 'linear': + return 'Mental Math (Linear)' + default: + return type + } +} + export default SessionSummary diff --git a/apps/web/src/lib/curriculum/progress-manager.ts b/apps/web/src/lib/curriculum/progress-manager.ts index a7e483ae..a77e07ab 100644 --- a/apps/web/src/lib/curriculum/progress-manager.ts +++ b/apps/web/src/lib/curriculum/progress-manager.ts @@ -126,6 +126,66 @@ export async function getSkillsByMasteryLevel( }) } +/** + * Set skills as mastered directly (manual teacher override) + * This is used for onboarding when a teacher knows which skills a student has already mastered. + * Skills not in the masteredSkillIds list will be set to 'learning' (or created as such). + * + * @param playerId - The player to update + * @param masteredSkillIds - Array of skill IDs to mark as mastered + * @returns All skill mastery records for the player after update + */ +export async function setMasteredSkills( + playerId: string, + masteredSkillIds: string[] +): Promise { + const now = new Date() + const masteredSet = new Set(masteredSkillIds) + + // Get all existing skills for this player + const existingSkills = await getAllSkillMastery(playerId) + const existingSkillIds = new Set(existingSkills.map((s) => s.skillId)) + + // Update existing skills + for (const skill of existingSkills) { + const shouldBeMastered = masteredSet.has(skill.skillId) + const newLevel = shouldBeMastered ? 'mastered' : 'learning' + + // Only update if the level changed + if (skill.masteryLevel !== newLevel) { + await db + .update(schema.playerSkillMastery) + .set({ + masteryLevel: newLevel, + // If marking as mastered, set reasonable stats + attempts: shouldBeMastered ? Math.max(skill.attempts, 10) : skill.attempts, + correct: shouldBeMastered ? Math.max(skill.correct, 10) : skill.correct, + consecutiveCorrect: shouldBeMastered ? 5 : 0, + updatedAt: now, + }) + .where(eq(schema.playerSkillMastery.id, skill.id)) + } + } + + // Create new skills that don't exist yet + for (const skillId of masteredSkillIds) { + if (!existingSkillIds.has(skillId)) { + const newRecord: NewPlayerSkillMastery = { + playerId, + skillId, + attempts: 10, + correct: 10, + consecutiveCorrect: 5, + masteryLevel: 'mastered', + lastPracticedAt: now, + } + await db.insert(schema.playerSkillMastery).values(newRecord) + } + } + + return getAllSkillMastery(playerId) +} + /** * Record a skill attempt (correct or incorrect) * Updates the skill mastery record and recalculates mastery level diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts index fd7c714d..d2e3bd28 100644 --- a/apps/web/src/lib/curriculum/session-planner.ts +++ b/apps/web/src/lib/curriculum/session-planner.ts @@ -35,7 +35,7 @@ import { type CurriculumPhase, getPhase, getPhaseDisplayInfo, - getPhaseSkillConstraints, + type getPhaseSkillConstraints, } from './definitions' import { generateProblemFromConstraints } from './problem-generator' import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager' @@ -104,12 +104,15 @@ export async function generateSessionPlan( const avgTimeSeconds = calculateAvgTimePerProblem(recentSessions) || config.defaultSecondsPerProblem - // 3. Categorize skills by need - const phaseConstraints = getPhaseSkillConstraints(currentPhaseId) - const struggling = findStrugglingSkills(skillMastery, phaseConstraints) + // 3. Build skill constraints from the student's ACTUAL mastered skills + const masteredSkills = skillMastery.filter((s) => s.masteryLevel === 'mastered') + const masteredSkillConstraints = buildConstraintsFromMasteredSkills(masteredSkills) + + // Categorize skills for review/reinforcement purposes + const struggling = findStrugglingSkills(skillMastery) const needsReview = findSkillsNeedingReview(skillMastery, config.reviewIntervalDays) - // 4. Build three parts + // 4. Build three parts using STUDENT'S MASTERED SKILLS const parts: SessionPart[] = [ buildSessionPart( 1, @@ -117,7 +120,7 @@ export async function generateSessionPlan( durationMinutes, avgTimeSeconds, config, - phaseConstraints, + masteredSkillConstraints, struggling, needsReview, currentPhase @@ -128,7 +131,7 @@ export async function generateSessionPlan( durationMinutes, avgTimeSeconds, config, - phaseConstraints, + masteredSkillConstraints, struggling, needsReview, currentPhase @@ -139,7 +142,7 @@ export async function generateSessionPlan( durationMinutes, avgTimeSeconds, config, - phaseConstraints, + masteredSkillConstraints, struggling, needsReview, currentPhase @@ -227,9 +230,9 @@ function buildSessionPart( ) } - // Challenge slots: slightly harder or mixed + // Challenge slots: use same mastered skills constraints (all problems should use student's skills) for (let i = 0; i < challengeCount; i++) { - slots.push(createSlot(slots.length, 'challenge', buildChallengeConstraints(currentPhase))) + slots.push(createSlot(slots.length, 'challenge', phaseConstraints)) } // Shuffle to interleave purposes @@ -520,10 +523,7 @@ function calculateAvgTimePerProblem( return Math.round(weightedSum / totalProblems / 1000) // Convert ms to seconds } -function findStrugglingSkills( - mastery: PlayerSkillMastery[], - _phaseConstraints: ReturnType -): PlayerSkillMastery[] { +function findStrugglingSkills(mastery: PlayerSkillMastery[]): PlayerSkillMastery[] { return mastery.filter((s) => { if (s.attempts < 5) return false // Not enough data const accuracy = s.correct / s.attempts @@ -552,6 +552,44 @@ function findSkillsNeedingReview( }) } +/** + * Build skill constraints from the student's actual mastered skills + * + * This creates constraints where: + * - requiredSkills: all mastered skills (problems may ONLY use these skills) + * - targetSkills: all mastered skills (prefer to use these skills) + * - forbiddenSkills: empty (don't exclude anything explicitly) + * + * The problem generator filters candidates to only allow requiredSkills, + * then preferentially selects candidates that use targetSkills. + */ +function buildConstraintsFromMasteredSkills( + masteredSkills: PlayerSkillMastery[] +): ReturnType { + const skills: Record> = {} + + for (const skill of masteredSkills) { + // Parse skill ID format: "category.skillKey" like "fiveComplements.4=5-1" or "basic.+3" + const [category, skillKey] = skill.skillId.split('.') + + if (category && skillKey) { + if (!skills[category]) { + skills[category] = {} + } + skills[category][skillKey] = true + } + } + + // Both required and target use the same skills: + // - requiredSkills: only these skills may be used (acts as filter) + // - targetSkills: prefer to use these skills (acts as preference) + return { + requiredSkills: skills, + targetSkills: skills, + forbiddenSkills: {}, + } as ReturnType +} + function buildConstraintsForSkill( skill: PlayerSkillMastery ): ReturnType { @@ -573,22 +611,6 @@ function buildConstraintsForSkill( return constraints as ReturnType } -function buildChallengeConstraints( - phase: CurriculumPhase | undefined -): ReturnType { - if (!phase) { - return { - requiredSkills: {}, - targetSkills: {}, - forbiddenSkills: {}, - } as ReturnType - } - - // For challenge, we use the same phase but with potentially harder settings - const constraints = getPhaseSkillConstraints(phase.id) - return constraints -} - /** * Shuffle slots while keeping some focus problems clustered * This prevents too much context switching while still providing variety