diff --git a/apps/web/src/components/practice/ActiveSession.stories.tsx b/apps/web/src/components/practice/ActiveSession.stories.tsx index 74a60000..50374c60 100644 --- a/apps/web/src/components/practice/ActiveSession.stories.tsx +++ b/apps/web/src/components/practice/ActiveSession.stories.tsx @@ -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), + } + }) } /** diff --git a/apps/web/src/components/practice/ProgressDashboard.stories.tsx b/apps/web/src/components/practice/ProgressDashboard.stories.tsx index b20f4c0e..fedd8195 100644 --- a/apps/web/src/components/practice/ProgressDashboard.stories.tsx +++ b/apps/web/src/components/practice/ProgressDashboard.stories.tsx @@ -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 = { ), } + +/** + * With Focus Areas - showing skills needing reinforcement + */ +export const WithFocusAreas: Story = { + render: () => ( + + alert(`Clear reinforcement for ${skillId}`)} + onClearAllReinforcement={() => alert('Clear all reinforcement')} + {...handlers} + /> + + ), +} diff --git a/apps/web/src/components/practice/ProgressDashboard.tsx b/apps/web/src/components/practice/ProgressDashboard.tsx index 8b575150..1e0d4591 100644 --- a/apps/web/src/components/practice/ProgressDashboard.tsx +++ b/apps/web/src/components/practice/ProgressDashboard.tsx @@ -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({ + {/* Focus Areas - Skills needing extra practice */} + {focusAreas.length > 0 && ( +
+
+

+ 🎯 + Focus Areas +

+ {onClearAllReinforcement && focusAreas.length > 1 && ( + + )} +
+

+ These skills need extra practice: +

+
+ {focusAreas.map((skill) => ( +
+
+ + {skill.skillName} + + {skill.reinforcementStreak !== undefined && skill.reinforcementStreak > 0 && ( + + ({skill.reinforcementStreak}/3) + + )} +
+ {onClearReinforcement && ( + + )} +
+ ))} +
+
+ )} + {/* Onboarding & Assessment Tools */} {(onRunPlacementTest || onSetSkillsManually || onRecordOfflinePractice) && (
+ {skill.needsReinforcement && ⚠️} {skill.skillName} ) diff --git a/apps/web/src/db/schema/players.ts b/apps/web/src/db/schema/players.ts index 8d539058..b5344792 100644 --- a/apps/web/src/db/schema/players.ts +++ b/apps/web/src/db/schema/players.ts @@ -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() - .default(JSON.stringify(DEFAULT_HELP_SETTINGS)), + helpSettings: text('help_settings', { mode: 'json' }).$type(), }, (table) => ({ /** Index for fast lookups by userId */ diff --git a/apps/web/src/hooks/useUserPlayers.ts b/apps/web/src/hooks/useUserPlayers.ts index 5fc56ab9..2132b388 100644 --- a/apps/web/src/hooks/useUserPlayers.ts +++ b/apps/web/src/hooks/useUserPlayers.ts @@ -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(playerKeys.list(), [ ...previousPlayers, diff --git a/apps/web/src/lib/curriculum/progress-manager.ts b/apps/web/src/lib/curriculum/progress-manager.ts index 317e23b2..a7e483ae 100644 --- a/apps/web/src/lib/curriculum/progress-manager.ts +++ b/apps/web/src/lib/curriculum/progress-manager.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) */