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)
*/