feat(help-system): add focus areas for skills needing reinforcement

Add tracking and display of skills that need extra practice:
- Add needsReinforcement, lastHelpLevel, reinforcementStreak to SkillProgress
- Add Focus Areas section to ProgressDashboard
- Add teacher controls to clear reinforcement flags
- Simplify helpSettings schema (remove default value from schema)

🤖 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-06 19:12:13 -06:00
parent 3ce12c59fc
commit 871390d8e1
6 changed files with 395 additions and 24 deletions

View File

@@ -88,24 +88,30 @@ function createMockSlotsWithProblems(
skillLevel: 'basic' | 'fiveComplements' | 'tenComplements',
purposes: Array<'focus' | 'reinforce' | 'review' | 'challenge'> = ['focus', 'reinforce', 'review']
): ProblemSlot[] {
return Array.from({ length: count }, (_, i) => ({
index: i,
purpose: purposes[i % purposes.length],
constraints: {
requiredSkills: {
basic: { directAddition: true, heavenBead: true },
...(skillLevel !== 'basic' && {
fiveComplements: { '4=5-1': true, '3=5-2': true },
}),
...(skillLevel === 'tenComplements' && {
tenComplements: { '9=10-1': true, '8=10-2': true },
}),
return Array.from({ length: count }, (_, i) => {
// Build required skills based on skill level
// Using type assertion since we're building mock data with partial values
const requiredSkills: ProblemSlot['constraints']['requiredSkills'] = {
basic: { directAddition: true, heavenBead: true },
...(skillLevel !== 'basic' && {
fiveComplements: { '4=5-1': true, '3=5-2': true },
}),
...(skillLevel === 'tenComplements' && {
tenComplements: { '9=10-1': true, '8=10-2': true },
}),
} as ProblemSlot['constraints']['requiredSkills']
return {
index: i,
purpose: purposes[i % purposes.length],
constraints: {
requiredSkills,
digitRange: { min: 1, max: skillLevel === 'tenComplements' ? 2 : 1 },
termCount: { min: 3, max: 4 },
},
digitRange: { min: 1, max: skillLevel === 'tenComplements' ? 2 : 1 },
termCount: { min: 3, max: 4 },
},
problem: generateProblemWithSkills(skillLevel),
}))
problem: generateProblemWithSkills(skillLevel),
}
})
}
/**

View File

@@ -21,7 +21,7 @@ const sampleStudent: StudentWithProgress = {
name: 'Sonia',
emoji: '🦋',
color: '#FFE4E1',
isGuest: false,
isActive: true,
currentLevel: 3,
currentPhaseId: 'five-complements-1',
masteryPercent: 75,
@@ -192,7 +192,7 @@ export const NewStudent: Story = {
name: 'New Learner',
emoji: '🌟',
color: '#FFFACD',
isGuest: false,
isActive: true,
createdAt: new Date(),
}}
currentPhase={{
@@ -226,7 +226,7 @@ export const DifferentStudents: Story = {
student={{
id: `student-${student.name}`,
...student,
isGuest: false,
isActive: true,
createdAt: new Date(),
}}
currentPhase={intermediatePhase}
@@ -237,3 +237,45 @@ export const DifferentStudents: Story = {
</div>
),
}
/**
* With Focus Areas - showing skills needing reinforcement
*/
export const WithFocusAreas: Story = {
render: () => (
<DashboardWrapper>
<ProgressDashboard
student={sampleStudent}
currentPhase={intermediatePhase}
recentSkills={sampleRecentSkills}
focusAreas={[
{
skillId: 'fiveComplements.3=5-2',
skillName: '+3 Five Complement',
masteryLevel: 'practicing',
attempts: 15,
correct: 10,
consecutiveCorrect: 1,
needsReinforcement: true,
lastHelpLevel: 2,
reinforcementStreak: 1,
},
{
skillId: 'tenComplements.8=10-2',
skillName: '+8 Ten Complement',
masteryLevel: 'learning',
attempts: 8,
correct: 4,
consecutiveCorrect: 0,
needsReinforcement: true,
lastHelpLevel: 3,
reinforcementStreak: 0,
},
]}
onClearReinforcement={(skillId) => alert(`Clear reinforcement for ${skillId}`)}
onClearAllReinforcement={() => alert('Clear all reinforcement')}
{...handlers}
/>
</DashboardWrapper>
),
}

View File

@@ -14,6 +14,12 @@ export interface SkillProgress {
attempts: number
correct: number
consecutiveCorrect: number
/** Whether this skill needs reinforcement (used heavy help recently) */
needsReinforcement?: boolean
/** Last help level used on this skill (0-3) */
lastHelpLevel?: number
/** Progress toward clearing reinforcement (0-3) */
reinforcementStreak?: number
}
/**
@@ -33,6 +39,8 @@ interface ProgressDashboardProps {
student: StudentWithProgress
currentPhase: CurrentPhaseInfo
recentSkills?: SkillProgress[]
/** Skills that need extra practice (used heavy help recently) */
focusAreas?: SkillProgress[]
onContinuePractice: () => void
onViewFullProgress: () => void
onGenerateWorksheet: () => void
@@ -43,6 +51,10 @@ interface ProgressDashboardProps {
onSetSkillsManually?: () => void
/** Callback to record offline practice */
onRecordOfflinePractice?: () => void
/** Callback to clear reinforcement for a skill (teacher only) */
onClearReinforcement?: (skillId: string) => void
/** Callback to clear all reinforcement flags (teacher only) */
onClearAllReinforcement?: () => void
}
/**
@@ -73,6 +85,7 @@ export function ProgressDashboard({
student,
currentPhase,
recentSkills = [],
focusAreas = [],
onContinuePractice,
onViewFullProgress,
onGenerateWorksheet,
@@ -80,6 +93,8 @@ export function ProgressDashboard({
onRunPlacementTest,
onSetSkillsManually,
onRecordOfflinePractice,
onClearReinforcement,
onClearAllReinforcement,
}: ProgressDashboardProps) {
const progressPercent =
currentPhase.totalSkills > 0
@@ -317,6 +332,140 @@ export function ProgressDashboard({
</div>
</div>
{/* Focus Areas - Skills needing extra practice */}
{focusAreas.length > 0 && (
<div
data-section="focus-areas"
className={css({
width: '100%',
padding: '1rem',
backgroundColor: 'orange.50',
borderRadius: '12px',
border: '1px solid',
borderColor: 'orange.200',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.75rem',
})}
>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'orange.700',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span>🎯</span>
Focus Areas
</h3>
{onClearAllReinforcement && focusAreas.length > 1 && (
<button
type="button"
data-action="clear-all-reinforcement"
onClick={onClearAllReinforcement}
className={css({
fontSize: '0.75rem',
color: 'gray.500',
background: 'none',
border: 'none',
cursor: 'pointer',
_hover: {
color: 'gray.700',
textDecoration: 'underline',
},
})}
>
Clear All
</button>
)}
</div>
<p
className={css({
fontSize: '0.75rem',
color: 'orange.600',
marginBottom: '0.75rem',
})}
>
These skills need extra practice:
</p>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{focusAreas.map((skill) => (
<div
key={skill.skillId}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.5rem 0.75rem',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid',
borderColor: 'orange.100',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '0.5rem' })}>
<span
className={css({
fontSize: '0.875rem',
color: 'gray.700',
fontWeight: 'medium',
})}
>
{skill.skillName}
</span>
{skill.reinforcementStreak !== undefined && skill.reinforcementStreak > 0 && (
<span
className={css({
fontSize: '0.75rem',
color: 'green.600',
})}
title={`${skill.reinforcementStreak} correct answers toward clearing`}
>
({skill.reinforcementStreak}/3)
</span>
)}
</div>
{onClearReinforcement && (
<button
type="button"
data-action="clear-reinforcement"
onClick={() => onClearReinforcement(skill.skillId)}
className={css({
fontSize: '0.75rem',
color: 'gray.400',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
_hover: {
color: 'gray.600',
},
})}
title="Mark as mastered (teacher only)"
>
</button>
)}
</div>
))}
</div>
</div>
)}
{/* Onboarding & Assessment Tools */}
{(onRunPlacementTest || onSetSkillsManually || onRecordOfflinePractice) && (
<div
@@ -457,13 +606,17 @@ export function ProgressDashboard({
fontSize: '0.75rem',
padding: '0.25rem 0.75rem',
borderRadius: '9999px',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
})}
style={{
backgroundColor: `var(--colors-${colors.bg.replace('.', '-')})`,
color: `var(--colors-${colors.text.replace('.', '-')})`,
}}
title={`${skill.correct}/${skill.attempts} correct, ${skill.consecutiveCorrect} in a row`}
title={`${skill.correct}/${skill.attempts} correct, ${skill.consecutiveCorrect} in a row${skill.needsReinforcement ? ' (needs practice)' : ''}`}
>
{skill.needsReinforcement && <span title="Needs practice"></span>}
{skill.skillName}
</span>
)

View File

@@ -84,9 +84,7 @@ export const players = sqliteTable(
* Help settings for practice sessions
* Controls how help is triggered and escalated
*/
helpSettings: text('help_settings', { mode: 'json' })
.$type<StudentHelpSettings>()
.default(JSON.stringify(DEFAULT_HELP_SETTINGS)),
helpSettings: text('help_settings', { mode: 'json' }).$type<StudentHelpSettings>(),
},
(table) => ({
/** Index for fast lookups by userId */

View File

@@ -111,6 +111,7 @@ export function useCreatePlayer() {
createdAt: new Date(),
isActive: newPlayer.isActive ?? false,
userId: 'temp-user', // Temporary userId, will be replaced by server response
helpSettings: null, // Will be set by server with default values
}
queryClient.setQueryData<Player[]>(playerKeys.list(), [
...previousPlayers,

View File

@@ -11,8 +11,10 @@ import {
type MasteryLevel,
type NewPlayerSkillMastery,
type PlayerSkillMastery,
REINFORCEMENT_CONFIG,
} from '@/db/schema/player-skill-mastery'
import type { NewPracticeSession, PracticeSession } from '@/db/schema/practice-sessions'
import type { HelpLevel } from '@/db/schema/session-plans'
// ============================================================================
// CURRICULUM POSITION OPERATIONS
@@ -173,6 +175,175 @@ export async function recordSkillAttempt(
return (await getSkillMastery(playerId, skillId))!
}
/**
* Record a skill attempt with help level tracking
* Applies credit multipliers based on help used and manages reinforcement
*
* Credit multipliers:
* - L0 (no help) or L1 (hint): Full credit (1.0)
* - L2 (decomposition): Half credit (0.5)
* - L3 (bead arrows): Quarter credit (0.25)
*
* Reinforcement logic:
* - If help level >= threshold, mark skill as needing reinforcement
* - If correct answer without heavy help, increment reinforcement streak
* - After N consecutive correct answers, clear reinforcement flag
*/
export async function recordSkillAttemptWithHelp(
playerId: string,
skillId: string,
isCorrect: boolean,
helpLevel: HelpLevel
): Promise<PlayerSkillMastery> {
const existing = await getSkillMastery(playerId, skillId)
const now = new Date()
// Calculate effective credit based on help level
const creditMultiplier = REINFORCEMENT_CONFIG.creditMultipliers[helpLevel]
// Determine if this help level triggers reinforcement tracking
const isHeavyHelp = helpLevel >= REINFORCEMENT_CONFIG.helpLevelThreshold
if (existing) {
// Update existing record with help-adjusted progress
const newAttempts = existing.attempts + 1
// Apply credit multiplier - only count fraction of correct answer
// For simplicity, we round: 1.0 = full credit, 0.5+ = credit, <0.5 = no credit
const effectiveCorrect = isCorrect && creditMultiplier >= 0.5 ? 1 : 0
const newCorrect = existing.correct + effectiveCorrect
// Consecutive streak logic with help consideration
// Heavy help (L2, L3) breaks the streak even if correct
const newConsecutive =
isCorrect && !isHeavyHelp ? existing.consecutiveCorrect + 1 : isCorrect ? 1 : 0
const newMasteryLevel = calculateMasteryLevel(newAttempts, newCorrect, newConsecutive)
// Reinforcement tracking
let needsReinforcement = existing.needsReinforcement
let reinforcementStreak = existing.reinforcementStreak
if (isHeavyHelp) {
// Heavy help triggers reinforcement flag
needsReinforcement = true
reinforcementStreak = 0
} else if (isCorrect && existing.needsReinforcement) {
// Correct answer without heavy help - increment streak toward clearing
reinforcementStreak = existing.reinforcementStreak + 1
// Clear reinforcement if streak reaches threshold
if (reinforcementStreak >= REINFORCEMENT_CONFIG.streakToClear) {
needsReinforcement = false
reinforcementStreak = 0
}
} else if (!isCorrect) {
// Incorrect answer resets reinforcement streak
reinforcementStreak = 0
}
await db
.update(schema.playerSkillMastery)
.set({
attempts: newAttempts,
correct: newCorrect,
consecutiveCorrect: newConsecutive,
masteryLevel: newMasteryLevel,
lastPracticedAt: now,
updatedAt: now,
needsReinforcement,
lastHelpLevel: helpLevel,
reinforcementStreak,
})
.where(eq(schema.playerSkillMastery.id, existing.id))
return (await getSkillMastery(playerId, skillId))!
}
// Create new record with help tracking
const newRecord: NewPlayerSkillMastery = {
playerId,
skillId,
attempts: 1,
correct: isCorrect && creditMultiplier >= 0.5 ? 1 : 0,
consecutiveCorrect: isCorrect && !isHeavyHelp ? 1 : 0,
masteryLevel: 'learning',
lastPracticedAt: now,
needsReinforcement: isHeavyHelp,
lastHelpLevel: helpLevel,
reinforcementStreak: 0,
}
await db.insert(schema.playerSkillMastery).values(newRecord)
return (await getSkillMastery(playerId, skillId))!
}
/**
* Record multiple skill attempts with help tracking (for batch updates after a problem)
*/
export async function recordSkillAttemptsWithHelp(
playerId: string,
skillResults: Array<{ skillId: string; isCorrect: boolean }>,
helpLevel: HelpLevel
): Promise<PlayerSkillMastery[]> {
const results: PlayerSkillMastery[] = []
for (const { skillId, isCorrect } of skillResults) {
const result = await recordSkillAttemptWithHelp(playerId, skillId, isCorrect, helpLevel)
results.push(result)
}
return results
}
/**
* Get skills that need reinforcement for a player
*/
export async function getSkillsNeedingReinforcement(
playerId: string
): Promise<PlayerSkillMastery[]> {
return db.query.playerSkillMastery.findMany({
where: and(
eq(schema.playerSkillMastery.playerId, playerId),
eq(schema.playerSkillMastery.needsReinforcement, true)
),
orderBy: desc(schema.playerSkillMastery.lastPracticedAt),
})
}
/**
* Clear reinforcement for a specific skill (teacher override)
*/
export async function clearSkillReinforcement(playerId: string, skillId: string): Promise<void> {
await db
.update(schema.playerSkillMastery)
.set({
needsReinforcement: false,
reinforcementStreak: 0,
updatedAt: new Date(),
})
.where(
and(
eq(schema.playerSkillMastery.playerId, playerId),
eq(schema.playerSkillMastery.skillId, skillId)
)
)
}
/**
* Clear all reinforcement flags for a player (teacher override)
*/
export async function clearAllReinforcement(playerId: string): Promise<void> {
await db
.update(schema.playerSkillMastery)
.set({
needsReinforcement: false,
reinforcementStreak: 0,
updatedAt: new Date(),
})
.where(eq(schema.playerSkillMastery.playerId, playerId))
}
/**
* Record multiple skill attempts at once (for batch updates after a problem)
*/