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
|
* 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 { NextResponse } from 'next/server'
|
||||||
import { recordSkillAttempt } from '@/lib/curriculum/progress-manager'
|
import { recordSkillAttempt, setMasteredSkills } from '@/lib/curriculum/progress-manager'
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ playerId: string }>
|
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 })
|
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
|
focusDescription: string
|
||||||
/** Average seconds per problem based on student's history */
|
/** Average seconds per problem based on student's history */
|
||||||
avgSecondsPerProblem: number
|
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
|
* Calculate estimated session breakdown based on duration
|
||||||
*/
|
*/
|
||||||
|
|
@ -144,6 +176,7 @@ export function ConfigureClient({
|
||||||
existingPlan,
|
existingPlan,
|
||||||
focusDescription,
|
focusDescription,
|
||||||
avgSecondsPerProblem,
|
avgSecondsPerProblem,
|
||||||
|
masteredSkillIds,
|
||||||
}: ConfigureClientProps) {
|
}: ConfigureClientProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -595,6 +628,91 @@ export function ConfigureClient({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'
|
||||||
import { getPhaseDisplayInfo } from '@/lib/curriculum/definitions'
|
import { getPhaseDisplayInfo } from '@/lib/curriculum/definitions'
|
||||||
import {
|
import {
|
||||||
getActiveSessionPlan,
|
getActiveSessionPlan,
|
||||||
|
getAllSkillMastery,
|
||||||
getPlayer,
|
getPlayer,
|
||||||
getPlayerCurriculum,
|
getPlayerCurriculum,
|
||||||
getRecentSessions,
|
getRecentSessions,
|
||||||
|
|
@ -31,12 +32,13 @@ interface ConfigurePageProps {
|
||||||
export default async function ConfigurePage({ params }: ConfigurePageProps) {
|
export default async function ConfigurePage({ params }: ConfigurePageProps) {
|
||||||
const { studentId } = await params
|
const { studentId } = await params
|
||||||
|
|
||||||
// Fetch player, curriculum, sessions, and active session in parallel
|
// Fetch player, curriculum, sessions, skills, and active session in parallel
|
||||||
const [player, activeSession, curriculum, recentSessions] = await Promise.all([
|
const [player, activeSession, curriculum, recentSessions, skills] = await Promise.all([
|
||||||
getPlayer(studentId),
|
getPlayer(studentId),
|
||||||
getActiveSessionPlan(studentId),
|
getActiveSessionPlan(studentId),
|
||||||
getPlayerCurriculum(studentId),
|
getPlayerCurriculum(studentId),
|
||||||
getRecentSessions(studentId, 10),
|
getRecentSessions(studentId, 10),
|
||||||
|
getAllSkillMastery(studentId),
|
||||||
])
|
])
|
||||||
|
|
||||||
// 404 if player doesn't exist
|
// 404 if player doesn't exist
|
||||||
|
|
@ -63,6 +65,9 @@ export default async function ConfigurePage({ params }: ConfigurePageProps) {
|
||||||
avgSecondsPerProblem = Math.round(weightedSum / totalProblems / 1000)
|
avgSecondsPerProblem = Math.round(weightedSum / totalProblems / 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get mastered skills for display
|
||||||
|
const masteredSkills = skills.filter((s) => s.masteryLevel === 'mastered').map((s) => s.skillId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigureClient
|
<ConfigureClient
|
||||||
studentId={studentId}
|
studentId={studentId}
|
||||||
|
|
@ -70,6 +75,7 @@ export default async function ConfigurePage({ params }: ConfigurePageProps) {
|
||||||
existingPlan={activeSession}
|
existingPlan={activeSession}
|
||||||
focusDescription={phaseInfo.phaseName}
|
focusDescription={phaseInfo.phaseName}
|
||||||
avgSecondsPerProblem={avgSecondsPerProblem}
|
avgSecondsPerProblem={avgSecondsPerProblem}
|
||||||
|
masteredSkillIds={masteredSkills}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,11 +158,25 @@ export function DashboardClient({
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle saving manual skill selections
|
// Handle saving manual skill selections
|
||||||
const handleSaveManualSkills = useCallback(async (masteredSkillIds: string[]): Promise<void> => {
|
const handleSaveManualSkills = useCallback(
|
||||||
// TODO: Save skills to curriculum via API
|
async (masteredSkillIds: string[]): Promise<void> => {
|
||||||
console.log('Manual skills saved:', masteredSkillIds)
|
const response = await fetch(`/api/curriculum/${studentId}/skills`, {
|
||||||
setShowManualSkillModal(false)
|
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
|
// Handle opening offline session form
|
||||||
const handleRecordOfflinePractice = useCallback(() => {
|
const handleRecordOfflinePractice = useCallback(() => {
|
||||||
|
|
@ -246,6 +260,9 @@ export function DashboardClient({
|
||||||
open={showManualSkillModal}
|
open={showManualSkillModal}
|
||||||
onClose={() => setShowManualSkillModal(false)}
|
onClose={() => setShowManualSkillModal(false)}
|
||||||
onSave={handleSaveManualSkills}
|
onSave={handleSaveManualSkills}
|
||||||
|
currentMasteredSkills={skills
|
||||||
|
.filter((s) => s.masteryLevel === 'mastered')
|
||||||
|
.map((s) => s.skillId)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Offline Session Form Modal */}
|
{/* Offline Session Form Modal */}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as Accordion from '@radix-ui/react-accordion'
|
import * as Accordion from '@radix-ui/react-accordion'
|
||||||
import * as Dialog from '@radix-ui/react-dialog'
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import { css } from '../../../styled-system/css'
|
import { css } from '../../../styled-system/css'
|
||||||
|
|
||||||
|
|
@ -192,6 +192,13 @@ export function ManualSkillSelector({
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
|
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) => {
|
const handlePresetChange = (presetKey: string) => {
|
||||||
if (presetKey === '') {
|
if (presetKey === '') {
|
||||||
// Clear all
|
// Clear all
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
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'
|
import { css } from '../../../styled-system/css'
|
||||||
|
|
||||||
interface SessionSummaryProps {
|
interface SessionSummaryProps {
|
||||||
|
|
@ -401,9 +401,89 @@ export function SessionSummary({
|
||||||
</div>
|
</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
|
<details
|
||||||
data-section="problem-review"
|
data-section="all-planned-problems"
|
||||||
className={css({
|
className={css({
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
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>
|
</summary>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -431,49 +511,173 @@ export function SessionSummary({
|
||||||
marginTop: '1rem',
|
marginTop: '1rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '0.5rem',
|
gap: '1.5rem',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{results.map((result, index) => (
|
{plan.parts.map((part) => (
|
||||||
<div
|
<div key={part.partNumber} data-element="part-section">
|
||||||
key={index}
|
<h4
|
||||||
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({
|
className={css({
|
||||||
flex: 1,
|
fontSize: '0.8125rem',
|
||||||
fontFamily: 'monospace',
|
fontWeight: 'bold',
|
||||||
|
color: isDark ? 'gray.400' : 'gray.600',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{formatProblem(result.problem.terms)} = {result.problem.answer}
|
Part {part.partNumber}: {getPartTypeName(part.type)}
|
||||||
</span>
|
|
||||||
{!result.isCorrect && (
|
|
||||||
<span
|
<span
|
||||||
className={css({
|
className={css({
|
||||||
color: isDark ? 'red.400' : 'red.600',
|
fontWeight: 'normal',
|
||||||
textDecoration: 'line-through',
|
marginLeft: '0.5rem',
|
||||||
|
textTransform: 'none',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{result.studentAnswer}
|
({part.slots.length} problems)
|
||||||
</span>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -592,4 +796,21 @@ function getPerformanceMessage(accuracy: number): string {
|
||||||
return "Keep practicing! You'll get better with each session!"
|
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
|
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)
|
* Record a skill attempt (correct or incorrect)
|
||||||
* Updates the skill mastery record and recalculates mastery level
|
* Updates the skill mastery record and recalculates mastery level
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import {
|
||||||
type CurriculumPhase,
|
type CurriculumPhase,
|
||||||
getPhase,
|
getPhase,
|
||||||
getPhaseDisplayInfo,
|
getPhaseDisplayInfo,
|
||||||
getPhaseSkillConstraints,
|
type getPhaseSkillConstraints,
|
||||||
} from './definitions'
|
} from './definitions'
|
||||||
import { generateProblemFromConstraints } from './problem-generator'
|
import { generateProblemFromConstraints } from './problem-generator'
|
||||||
import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
|
import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './progress-manager'
|
||||||
|
|
@ -104,12 +104,15 @@ export async function generateSessionPlan(
|
||||||
const avgTimeSeconds =
|
const avgTimeSeconds =
|
||||||
calculateAvgTimePerProblem(recentSessions) || config.defaultSecondsPerProblem
|
calculateAvgTimePerProblem(recentSessions) || config.defaultSecondsPerProblem
|
||||||
|
|
||||||
// 3. Categorize skills by need
|
// 3. Build skill constraints from the student's ACTUAL mastered skills
|
||||||
const phaseConstraints = getPhaseSkillConstraints(currentPhaseId)
|
const masteredSkills = skillMastery.filter((s) => s.masteryLevel === 'mastered')
|
||||||
const struggling = findStrugglingSkills(skillMastery, phaseConstraints)
|
const masteredSkillConstraints = buildConstraintsFromMasteredSkills(masteredSkills)
|
||||||
|
|
||||||
|
// Categorize skills for review/reinforcement purposes
|
||||||
|
const struggling = findStrugglingSkills(skillMastery)
|
||||||
const needsReview = findSkillsNeedingReview(skillMastery, config.reviewIntervalDays)
|
const needsReview = findSkillsNeedingReview(skillMastery, config.reviewIntervalDays)
|
||||||
|
|
||||||
// 4. Build three parts
|
// 4. Build three parts using STUDENT'S MASTERED SKILLS
|
||||||
const parts: SessionPart[] = [
|
const parts: SessionPart[] = [
|
||||||
buildSessionPart(
|
buildSessionPart(
|
||||||
1,
|
1,
|
||||||
|
|
@ -117,7 +120,7 @@ export async function generateSessionPlan(
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
avgTimeSeconds,
|
avgTimeSeconds,
|
||||||
config,
|
config,
|
||||||
phaseConstraints,
|
masteredSkillConstraints,
|
||||||
struggling,
|
struggling,
|
||||||
needsReview,
|
needsReview,
|
||||||
currentPhase
|
currentPhase
|
||||||
|
|
@ -128,7 +131,7 @@ export async function generateSessionPlan(
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
avgTimeSeconds,
|
avgTimeSeconds,
|
||||||
config,
|
config,
|
||||||
phaseConstraints,
|
masteredSkillConstraints,
|
||||||
struggling,
|
struggling,
|
||||||
needsReview,
|
needsReview,
|
||||||
currentPhase
|
currentPhase
|
||||||
|
|
@ -139,7 +142,7 @@ export async function generateSessionPlan(
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
avgTimeSeconds,
|
avgTimeSeconds,
|
||||||
config,
|
config,
|
||||||
phaseConstraints,
|
masteredSkillConstraints,
|
||||||
struggling,
|
struggling,
|
||||||
needsReview,
|
needsReview,
|
||||||
currentPhase
|
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++) {
|
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
|
// Shuffle to interleave purposes
|
||||||
|
|
@ -520,10 +523,7 @@ function calculateAvgTimePerProblem(
|
||||||
return Math.round(weightedSum / totalProblems / 1000) // Convert ms to seconds
|
return Math.round(weightedSum / totalProblems / 1000) // Convert ms to seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
function findStrugglingSkills(
|
function findStrugglingSkills(mastery: PlayerSkillMastery[]): PlayerSkillMastery[] {
|
||||||
mastery: PlayerSkillMastery[],
|
|
||||||
_phaseConstraints: ReturnType<typeof getPhaseSkillConstraints>
|
|
||||||
): PlayerSkillMastery[] {
|
|
||||||
return mastery.filter((s) => {
|
return mastery.filter((s) => {
|
||||||
if (s.attempts < 5) return false // Not enough data
|
if (s.attempts < 5) return false // Not enough data
|
||||||
const accuracy = s.correct / s.attempts
|
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(
|
function buildConstraintsForSkill(
|
||||||
skill: PlayerSkillMastery
|
skill: PlayerSkillMastery
|
||||||
): ReturnType<typeof getPhaseSkillConstraints> {
|
): ReturnType<typeof getPhaseSkillConstraints> {
|
||||||
|
|
@ -573,22 +611,6 @@ function buildConstraintsForSkill(
|
||||||
return constraints as ReturnType<typeof getPhaseSkillConstraints>
|
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
|
* Shuffle slots while keeping some focus problems clustered
|
||||||
* This prevents too much context switching while still providing variety
|
* This prevents too much context switching while still providing variety
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue