feat(practice): use student's actual mastered skills for problem generation

Major fix: Session planner now uses the student's actual mastered skills
from the database instead of hardcoded phase-based constraints.

Changes:
- Add buildConstraintsFromMasteredSkills() to convert student skill records
  to problem generator constraints
- Session planner fetches mastered skills and passes them to problem generator
- Skills set via ManualSkillSelector now actually affect generated problems
- Remove unused buildChallengeConstraints() function
- Fix findStrugglingSkills() signature (remove unused param)

Also includes supporting changes from previous session:
- Add setMasteredSkills() to progress-manager for persisting skills
- Add PUT endpoint to skills/route.ts for saving mastered skills
- Display mastered skills in session configure preview
- Add "View All Planned Problems" section to SessionSummary
- Sync ManualSkillSelector state when modal opens

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-09 19:18:48 -06:00
parent c19109758a
commit 245cc269fe
8 changed files with 562 additions and 77 deletions

View File

@ -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 })
}
}

View File

@ -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<string, string> = {
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<string, string[]> {
const grouped = new Map<string, string[]>()
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({
</div>
</div>
</div>
{/* Mastered Skills Summary (collapsible) */}
<details
data-section="skills-summary"
className={css({
marginTop: '1rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
paddingTop: '1rem',
})}
>
<summary
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
_hover: { color: isDark ? 'gray.300' : 'gray.600' },
})}
>
Mastered Skills ({masteredSkillIds.length})
</summary>
<div className={css({ marginTop: '0.75rem' })}>
{masteredSkillIds.length === 0 ? (
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.500' : 'gray.400',
fontStyle: 'italic',
})}
>
No skills marked as mastered yet. Go to Dashboard to set skills.
</p>
) : (
<div
className={css({ display: 'flex', flexDirection: 'column', gap: '0.5rem' })}
>
{Array.from(groupSkillsByCategory(masteredSkillIds)).map(
([category, skills]) => (
<div key={category}>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.600',
marginBottom: '0.25rem',
})}
>
{SKILL_CATEGORY_NAMES[category] || category}
</div>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.25rem',
})}
>
{skills.map((skill) => (
<span
key={skill}
className={css({
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
backgroundColor: isDark ? 'green.900' : 'green.100',
color: isDark ? 'green.300' : 'green.700',
})}
>
{skill}
</span>
))}
</div>
</div>
)
)}
</div>
)}
</div>
</details>
</div>
</div>

View File

@ -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 (
<ConfigureClient
studentId={studentId}
@ -70,6 +75,7 @@ export default async function ConfigurePage({ params }: ConfigurePageProps) {
existingPlan={activeSession}
focusDescription={phaseInfo.phaseName}
avgSecondsPerProblem={avgSecondsPerProblem}
masteredSkillIds={masteredSkills}
/>
)
}

View File

@ -158,11 +158,25 @@ export function DashboardClient({
}, [])
// Handle saving manual skill selections
const handleSaveManualSkills = useCallback(async (masteredSkillIds: string[]): Promise<void> => {
// TODO: Save skills to curriculum via API
console.log('Manual skills saved:', masteredSkillIds)
setShowManualSkillModal(false)
}, [])
const handleSaveManualSkills = useCallback(
async (masteredSkillIds: string[]): Promise<void> => {
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 */}

View File

@ -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<string[]>([])
// 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

View File

@ -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({
</div>
)}
{/* Problem review (collapsed by default) */}
{/* Problem review - answered problems with results (collapsed by default) */}
{results.length > 0 && (
<details
data-section="problem-review"
className={css({
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<summary
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.300' : 'gray.700',
cursor: 'pointer',
_hover: {
color: isDark ? 'gray.100' : 'gray.900',
},
})}
>
Review Answered Problems ({totalProblems})
</summary>
<div
className={css({
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{results.map((result, index) => (
<div
key={index}
data-element="problem-review-item"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem',
backgroundColor: isDark
? result.isCorrect
? 'green.900'
: 'red.900'
: result.isCorrect
? 'green.50'
: 'red.50',
borderRadius: '8px',
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'inherit',
})}
>
<span>{result.isCorrect ? '✓' : '✗'}</span>
<span
className={css({
flex: 1,
fontFamily: 'monospace',
})}
>
{formatProblem(result.problem.terms)} = {result.problem.answer}
</span>
{!result.isCorrect && (
<span
className={css({
color: isDark ? 'red.400' : 'red.600',
textDecoration: 'line-through',
})}
>
{result.studentAnswer}
</span>
)}
</div>
))}
</div>
</details>
)}
{/* View all planned problems - shows full problem set by part (for teachers) */}
<details
data-section="problem-review"
data-section="all-planned-problems"
className={css({
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'gray.50',
@ -423,7 +503,7 @@ export function SessionSummary({
},
})}
>
Review All Problems ({totalProblems})
View All Planned Problems ({getTotalPlannedProblems(plan.parts)})
</summary>
<div
@ -431,49 +511,173 @@ export function SessionSummary({
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
gap: '1.5rem',
})}
>
{results.map((result, index) => (
<div
key={index}
data-element="problem-review-item"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem',
backgroundColor: isDark
? result.isCorrect
? 'green.900'
: 'red.900'
: result.isCorrect
? 'green.50'
: 'red.50',
borderRadius: '8px',
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'inherit',
})}
>
<span>{result.isCorrect ? '✓' : '✗'}</span>
<span
{plan.parts.map((part) => (
<div key={part.partNumber} data-element="part-section">
<h4
className={css({
flex: 1,
fontFamily: 'monospace',
fontSize: '0.8125rem',
fontWeight: 'bold',
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
{formatProblem(result.problem.terms)} = {result.problem.answer}
</span>
{!result.isCorrect && (
Part {part.partNumber}: {getPartTypeName(part.type)}
<span
className={css({
color: isDark ? 'red.400' : 'red.600',
textDecoration: 'line-through',
fontWeight: 'normal',
marginLeft: '0.5rem',
textTransform: 'none',
})}
>
{result.studentAnswer}
({part.slots.length} problems)
</span>
)}
</h4>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
})}
>
{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 (
<div
key={slot.index}
data-element="planned-problem-item"
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.5rem',
backgroundColor: result
? isDark
? result.isCorrect
? 'green.900'
: 'red.900'
: result.isCorrect
? 'green.50'
: 'red.50'
: isDark
? 'gray.700'
: 'white',
borderRadius: '6px',
fontSize: '0.8125rem',
color: isDark ? 'gray.200' : 'inherit',
})}
>
{/* Status indicator */}
<span
className={css({
width: '1.25rem',
textAlign: 'center',
})}
>
{result ? (result.isCorrect ? '✓' : '✗') : '○'}
</span>
{/* Problem number */}
<span
className={css({
color: isDark ? 'gray.500' : 'gray.400',
fontSize: '0.75rem',
minWidth: '1.5rem',
})}
>
#{slot.index + 1}
</span>
{/* Problem content */}
{problem ? (
<span
className={css({
flex: 1,
fontFamily: 'monospace',
})}
>
{formatProblem(problem.terms)} = {problem.answer}
</span>
) : (
<span
className={css({
flex: 1,
fontStyle: 'italic',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
(not yet generated)
</span>
)}
{/* Purpose tag */}
<span
className={css({
fontSize: '0.625rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
textTransform: 'uppercase',
backgroundColor: isDark
? slot.purpose === 'focus'
? 'blue.900'
: slot.purpose === 'reinforce'
? 'purple.900'
: slot.purpose === 'challenge'
? 'orange.900'
: 'gray.700'
: slot.purpose === 'focus'
? 'blue.100'
: slot.purpose === 'reinforce'
? 'purple.100'
: slot.purpose === 'challenge'
? 'orange.100'
: 'gray.100',
color: isDark
? slot.purpose === 'focus'
? 'blue.300'
: slot.purpose === 'reinforce'
? 'purple.300'
: slot.purpose === 'challenge'
? 'orange.300'
: 'gray.400'
: slot.purpose === 'focus'
? 'blue.700'
: slot.purpose === 'reinforce'
? 'purple.700'
: slot.purpose === 'challenge'
? 'orange.700'
: 'gray.600',
})}
>
{slot.purpose}
</span>
{/* Wrong answer (if incorrect) */}
{result && !result.isCorrect && (
<span
className={css({
color: isDark ? 'red.400' : 'red.600',
textDecoration: 'line-through',
fontSize: '0.75rem',
})}
>
{result.studentAnswer}
</span>
)}
</div>
)
})}
</div>
</div>
))}
</div>
@ -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

View File

@ -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<PlayerSkillMastery[]> {
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

View File

@ -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<typeof getPhaseSkillConstraints>
): 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<typeof getPhaseSkillConstraints> {
const skills: Record<string, Record<string, boolean>> = {}
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<typeof getPhaseSkillConstraints>
}
function buildConstraintsForSkill(
skill: PlayerSkillMastery
): ReturnType<typeof getPhaseSkillConstraints> {
@ -573,22 +611,6 @@ function buildConstraintsForSkill(
return constraints as ReturnType<typeof getPhaseSkillConstraints>
}
function buildChallengeConstraints(
phase: CurriculumPhase | undefined
): ReturnType<typeof getPhaseSkillConstraints> {
if (!phase) {
return {
requiredSkills: {},
targetSkills: {},
forbiddenSkills: {},
} as ReturnType<typeof getPhaseSkillConstraints>
}
// 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