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:
parent
c19109758a
commit
245cc269fe
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue