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:
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user