From 373ec34e4649130c368c4ce193c8066c56be9744 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sat, 6 Dec 2025 15:27:51 -0600 Subject: [PATCH] feat(help-system): integrate PracticeHelpPanel into ActiveSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of help system implementation: New component: - PracticeHelpPanel.tsx: Progressive help display for practice sessions - L0: "Need Help?" button - L1: Coach hint with verbal guidance - L2: Mathematical decomposition with segment explanations - L3: Bead movement steps with instructions - Help level indicator dots - "More Help" escalation button - Max help level tracking display ActiveSession integration: - Added usePracticeHelp hook for help state management - Track running total and current term for help context - Reset help context when advancing to new term - Record help usage in SlotResult (helpLevelUsed, incorrectAttempts, helpTrigger) - Display PracticeHelpPanel after problem, before input area - Pass isAbacusPart to enable bead-specific help messaging Props added: - helpSettings: StudentHelpSettings for configurable help behavior - isBeginnerMode: Enable free help without mastery penalty Stories updated: - Fixed Date timestamp types - Added default help tracking fields in interactive demo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../practice/ActiveSession.stories.tsx | 10 +- .../src/components/practice/ActiveSession.tsx | 106 ++++- .../components/practice/PracticeHelpPanel.tsx | 420 ++++++++++++++++++ 3 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/practice/PracticeHelpPanel.tsx diff --git a/apps/web/src/components/practice/ActiveSession.stories.tsx b/apps/web/src/components/practice/ActiveSession.stories.tsx index 5a3cf7c9..74a60000 100644 --- a/apps/web/src/components/practice/ActiveSession.stories.tsx +++ b/apps/web/src/components/practice/ActiveSession.stories.tsx @@ -189,9 +189,9 @@ function createMockSessionPlanWithProblems(config: { sessionHealth: config.sessionHealth ?? null, adjustments: [], results: [], - createdAt: Date.now(), - approvedAt: Date.now() - 60000, - startedAt: Date.now() - 30000, + createdAt: new Date(), + approvedAt: new Date(Date.now() - 60000), + startedAt: new Date(Date.now() - 30000), completedAt: null, } } @@ -367,6 +367,10 @@ function InteractiveSessionDemo() { ...result, partNumber: (plan.currentPartIndex + 1) as 1 | 2 | 3, timestamp: new Date(), + // Default help tracking fields if not provided + helpLevelUsed: result.helpLevelUsed ?? 0, + incorrectAttempts: result.incorrectAttempts ?? 0, + helpTrigger: result.helpTrigger ?? 'none', } setResults((prev) => [...prev, fullResult]) diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 16d391fa..f269904a 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { GeneratedProblem, + HelpLevel, ProblemConstraints, ProblemSlot, SessionHealth, @@ -10,6 +11,8 @@ import type { SessionPlan, SlotResult, } from '@/db/schema/session-plans' +import type { StudentHelpSettings } from '@/db/schema/players' +import { usePracticeHelp, type TermContext } from '@/hooks/usePracticeHelp' import { createBasicSkillSet, type SkillSet } from '@/types/tutorial' import { analyzeRequiredSkills, @@ -19,6 +22,7 @@ import { import { css } from '../../../styled-system/css' import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection' import { NumericKeypad } from './NumericKeypad' +import { PracticeHelpPanel } from './PracticeHelpPanel' import { VerticalProblem } from './VerticalProblem' interface ActiveSessionProps { @@ -34,6 +38,10 @@ interface ActiveSessionProps { onResume?: () => void /** Called when session completes */ onComplete: () => void + /** Student's help settings (optional, uses defaults if not provided) */ + helpSettings?: StudentHelpSettings + /** Whether this student is in beginner mode (free help without penalty) */ + isBeginnerMode?: boolean } interface CurrentProblem { @@ -172,6 +180,8 @@ export function ActiveSession({ onPause, onResume, onComplete, + helpSettings, + isBeginnerMode = false, }: ActiveSessionProps) { const [currentProblem, setCurrentProblem] = useState(null) const [userAnswer, setUserAnswer] = useState('') @@ -179,9 +189,67 @@ export function ActiveSession({ const [isPaused, setIsPaused] = useState(false) const [showAbacus, setShowAbacus] = useState(false) const [feedback, setFeedback] = useState<'none' | 'correct' | 'incorrect'>('none') + const [currentTermIndex, setCurrentTermIndex] = useState(0) + const [incorrectAttempts, setIncorrectAttempts] = useState(0) const hasPhysicalKeyboard = useHasPhysicalKeyboard() + // Calculate running total and target for help context + const runningTotal = useMemo(() => { + if (!currentProblem) return 0 + const terms = currentProblem.problem.terms + let total = 0 + for (let i = 0; i < currentTermIndex; i++) { + total += terms[i] + } + return total + }, [currentProblem, currentTermIndex]) + + const currentTermTarget = useMemo(() => { + if (!currentProblem) return 0 + const terms = currentProblem.problem.terms + if (currentTermIndex >= terms.length) return currentProblem.problem.answer + return runningTotal + terms[currentTermIndex] + }, [currentProblem, currentTermIndex, runningTotal]) + + const currentTerm = useMemo(() => { + if (!currentProblem || currentTermIndex >= currentProblem.problem.terms.length) return 0 + return currentProblem.problem.terms[currentTermIndex] + }, [currentProblem, currentTermIndex]) + + // Initialize help system + const [helpState, helpActions] = usePracticeHelp({ + settings: helpSettings || { + helpMode: 'auto', + autoEscalationTimingMs: { level1: 30000, level2: 60000, level3: 90000 }, + beginnerFreeHelp: true, + advancedRequiresApproval: false, + }, + isBeginnerMode, + onHelpLevelChange: (level, trigger) => { + // Could add analytics tracking here + console.log(`Help level changed to ${level} via ${trigger}`) + }, + }) + + // Update help context when problem or term changes + useEffect(() => { + if (currentProblem && currentTermIndex < currentProblem.problem.terms.length) { + helpActions.resetForNewTerm({ + currentValue: runningTotal, + targetValue: currentTermTarget, + term: currentTerm, + termIndex: currentTermIndex, + }) + } + }, [ + currentProblem?.problem.terms.join(','), + currentTermIndex, + runningTotal, + currentTermTarget, + currentTerm, + ]) + // Get current part and slot const parts = plan.parts const currentPartIndex = plan.currentPartIndex @@ -271,7 +339,13 @@ export function ActiveSession({ // Show feedback setFeedback(isCorrect ? 'correct' : 'incorrect') - // Record the result + // Track incorrect attempts for help escalation + if (!isCorrect) { + setIncorrectAttempts((prev) => prev + 1) + helpActions.recordError() + } + + // Record the result with help tracking data const result: Omit = { slotIndex: currentProblem.slotIndex, problem: currentProblem.problem, @@ -280,6 +354,10 @@ export function ActiveSession({ responseTimeMs, skillsExercised: currentProblem.problem.skillsRequired, usedOnScreenAbacus: showAbacus, + // Help tracking fields + helpLevelUsed: helpState.maxLevelUsed, + incorrectAttempts, + helpTrigger: helpState.trigger, } await onAnswer(result) @@ -288,11 +366,23 @@ export function ActiveSession({ setTimeout( () => { setCurrentProblem(null) + setCurrentTermIndex(0) + setIncorrectAttempts(0) setIsSubmitting(false) }, isCorrect ? 500 : 1500 ) - }, [currentProblem, isSubmitting, userAnswer, showAbacus, onAnswer]) + }, [ + currentProblem, + isSubmitting, + userAnswer, + showAbacus, + onAnswer, + helpState.maxLevelUsed, + helpState.trigger, + incorrectAttempts, + helpActions, + ]) const handlePause = useCallback(() => { setIsPaused(true) @@ -563,6 +653,18 @@ export function ActiveSession({ : `The answer was ${currentProblem.problem.answer}`} )} + + {/* Help panel - show when not submitting and feedback hasn't been shown yet */} + {!isSubmitting && feedback === 'none' && ( +
+ +
+ )} {/* Input area */} diff --git a/apps/web/src/components/practice/PracticeHelpPanel.tsx b/apps/web/src/components/practice/PracticeHelpPanel.tsx new file mode 100644 index 00000000..3264c3d3 --- /dev/null +++ b/apps/web/src/components/practice/PracticeHelpPanel.tsx @@ -0,0 +1,420 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { HelpLevel } from '@/db/schema/session-plans' +import type { HelpContent, PracticeHelpState } from '@/hooks/usePracticeHelp' +import { css } from '../../../styled-system/css' + +interface PracticeHelpPanelProps { + /** Current help state from usePracticeHelp hook */ + helpState: PracticeHelpState + /** Request help at a specific level */ + onRequestHelp: (level?: HelpLevel) => void + /** Dismiss help (return to L0) */ + onDismissHelp: () => void + /** Whether this is the abacus part (enables bead arrows at L3) */ + isAbacusPart?: boolean +} + +/** + * Help level labels for display + */ +const HELP_LEVEL_LABELS: Record = { + 0: 'No Help', + 1: 'Hint', + 2: 'Show Steps', + 3: 'Show Beads', +} + +/** + * Help level icons + */ +const HELP_LEVEL_ICONS: Record = { + 0: '💡', + 1: '💬', + 2: '📝', + 3: '🧮', +} + +/** + * PracticeHelpPanel - Progressive help display for practice sessions + * + * Shows escalating levels of help: + * - L0: Just the "Need Help?" button + * - L1: Coach hint (verbal guidance) + * - L2: Mathematical decomposition with explanations + * - L3: Bead movement arrows (for abacus part) + */ +export function PracticeHelpPanel({ + helpState, + onRequestHelp, + onDismissHelp, + isAbacusPart = false, +}: PracticeHelpPanelProps) { + const { currentLevel, content, isAvailable, maxLevelUsed } = helpState + const [isExpanded, setIsExpanded] = useState(false) + + const handleRequestHelp = useCallback(() => { + if (currentLevel === 0) { + onRequestHelp(1) + setIsExpanded(true) + } else if (currentLevel < 3) { + onRequestHelp((currentLevel + 1) as HelpLevel) + } + }, [currentLevel, onRequestHelp]) + + const handleDismiss = useCallback(() => { + onDismissHelp() + setIsExpanded(false) + }, [onDismissHelp]) + + // Don't render if help is not available (e.g., sequence generation failed) + if (!isAvailable) { + return null + } + + // Level 0: Just show the help request button + if (currentLevel === 0) { + return ( +
+ +
+ ) + } + + // Levels 1-3: Show the help content + return ( +
+ {/* Header with level indicator */} +
+
+ {HELP_LEVEL_ICONS[currentLevel]} + + {HELP_LEVEL_LABELS[currentLevel]} + + {/* Help level indicator dots */} +
+ {[1, 2, 3].map((level) => ( +
+ ))} +
+
+ + +
+ + {/* Level 1: Coach hint */} + {currentLevel >= 1 && content?.coachHint && ( +
+

+ {content.coachHint} +

+
+ )} + + {/* Level 2: Decomposition */} + {currentLevel >= 2 && content?.decomposition && content.decomposition.isMeaningful && ( +
+
+ Step-by-Step +
+
+ {content.decomposition.fullDecomposition} +
+ + {/* Segment explanations */} + {content.decomposition.segments.length > 0 && ( +
+ {content.decomposition.segments.map((segment) => ( +
+ + {segment.readable?.title || `Column ${segment.place + 1}`}: + {' '} + + {segment.readable?.summary || segment.expression} + +
+ ))} +
+ )} +
+ )} + + {/* Level 3: Bead steps */} + {currentLevel >= 3 && content?.beadSteps && content.beadSteps.length > 0 && ( +
+
+ Bead Movements +
+
    + {content.beadSteps.map((step, index) => ( +
  1. + + {step.mathematicalTerm} + + {step.englishInstruction && ( + — {step.englishInstruction} + )} +
  2. + ))} +
+ + {isAbacusPart && ( +
+ Try following these steps on your abacus +
+ )} +
+ )} + + {/* More help button (if not at max level) */} + {currentLevel < 3 && ( + + )} + + {/* Max level indicator */} + {maxLevelUsed > 0 && ( +
+ Help used: Level {maxLevelUsed} +
+ )} +
+ ) +} + +export default PracticeHelpPanel