feat(help-system): integrate PracticeHelpPanel into ActiveSession

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-06 15:27:51 -06:00
parent 0b1ad1f896
commit 373ec34e46
3 changed files with 531 additions and 5 deletions

View File

@ -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])

View File

@ -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<CurrentProblem | null>(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<SlotResult, 'timestamp' | 'partNumber'> = {
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}`}
</div>
)}
{/* Help panel - show when not submitting and feedback hasn't been shown yet */}
{!isSubmitting && feedback === 'none' && (
<div data-section="help-area" className={css({ width: '100%' })}>
<PracticeHelpPanel
helpState={helpState}
onRequestHelp={helpActions.requestHelp}
onDismissHelp={helpActions.dismissHelp}
isAbacusPart={currentPart.type === 'abacus'}
/>
</div>
)}
</div>
{/* Input area */}

View File

@ -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<HelpLevel, string> = {
0: 'No Help',
1: 'Hint',
2: 'Show Steps',
3: 'Show Beads',
}
/**
* Help level icons
*/
const HELP_LEVEL_ICONS: Record<HelpLevel, string> = {
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 (
<div
data-component="practice-help-panel"
data-level={0}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
<button
type="button"
data-action="request-help"
onClick={handleRequestHelp}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
width: '100%',
padding: '0.75rem',
fontSize: '0.875rem',
color: 'blue.600',
backgroundColor: 'blue.50',
border: '1px solid',
borderColor: 'blue.200',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: 'blue.100',
borderColor: 'blue.300',
},
})}
>
<span>💡</span>
<span>Need Help?</span>
</button>
</div>
)
}
// Levels 1-3: Show the help content
return (
<div
data-component="practice-help-panel"
data-level={currentLevel}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '1rem',
backgroundColor: 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: 'blue.200',
})}
>
{/* Header with level indicator */}
<div
data-element="help-header"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span className={css({ fontSize: '1.25rem' })}>{HELP_LEVEL_ICONS[currentLevel]}</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: 'blue.700',
})}
>
{HELP_LEVEL_LABELS[currentLevel]}
</span>
{/* Help level indicator dots */}
<div
className={css({
display: 'flex',
gap: '0.25rem',
marginLeft: '0.5rem',
})}
>
{[1, 2, 3].map((level) => (
<div
key={level}
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: level <= currentLevel ? 'blue.500' : 'blue.200',
})}
/>
))}
</div>
</div>
<button
type="button"
data-action="dismiss-help"
onClick={handleDismiss}
className={css({
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
color: 'gray.500',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: {
color: 'gray.700',
},
})}
>
Hide
</button>
</div>
{/* Level 1: Coach hint */}
{currentLevel >= 1 && content?.coachHint && (
<div
data-element="coach-hint"
className={css({
padding: '0.75rem',
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: 'blue.100',
})}
>
<p
className={css({
fontSize: '1rem',
color: 'gray.700',
lineHeight: '1.5',
})}
>
{content.coachHint}
</p>
</div>
)}
{/* Level 2: Decomposition */}
{currentLevel >= 2 && content?.decomposition && content.decomposition.isMeaningful && (
<div
data-element="decomposition"
className={css({
padding: '0.75rem',
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: 'blue.100',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: 'blue.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
})}
>
Step-by-Step
</div>
<div
className={css({
fontFamily: 'monospace',
fontSize: '1.125rem',
color: 'gray.800',
wordBreak: 'break-word',
})}
>
{content.decomposition.fullDecomposition}
</div>
{/* Segment explanations */}
{content.decomposition.segments.length > 0 && (
<div
className={css({
marginTop: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{content.decomposition.segments.map((segment) => (
<div
key={segment.id}
className={css({
padding: '0.5rem',
backgroundColor: 'gray.50',
borderRadius: '6px',
fontSize: '0.875rem',
})}
>
<span
className={css({
fontWeight: 'bold',
color: 'gray.700',
})}
>
{segment.readable?.title || `Column ${segment.place + 1}`}:
</span>{' '}
<span className={css({ color: 'gray.600' })}>
{segment.readable?.summary || segment.expression}
</span>
</div>
))}
</div>
)}
</div>
)}
{/* Level 3: Bead steps */}
{currentLevel >= 3 && content?.beadSteps && content.beadSteps.length > 0 && (
<div
data-element="bead-steps"
className={css({
padding: '0.75rem',
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: 'blue.100',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: 'purple.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
})}
>
Bead Movements
</div>
<ol
className={css({
listStyle: 'decimal',
paddingLeft: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{content.beadSteps.map((step, index) => (
<li
key={index}
className={css({
fontSize: '0.875rem',
color: 'gray.700',
})}
>
<span
className={css({
fontWeight: 'bold',
color: 'purple.700',
})}
>
{step.mathematicalTerm}
</span>
{step.englishInstruction && (
<span className={css({ color: 'gray.600' })}> {step.englishInstruction}</span>
)}
</li>
))}
</ol>
{isAbacusPart && (
<div
className={css({
marginTop: '0.75rem',
padding: '0.5rem',
backgroundColor: 'purple.50',
borderRadius: '6px',
fontSize: '0.75rem',
color: 'purple.700',
textAlign: 'center',
})}
>
Try following these steps on your abacus
</div>
)}
</div>
)}
{/* More help button (if not at max level) */}
{currentLevel < 3 && (
<button
type="button"
data-action="escalate-help"
onClick={handleRequestHelp}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
width: '100%',
padding: '0.5rem',
fontSize: '0.875rem',
color: 'blue.600',
backgroundColor: 'white',
border: '1px solid',
borderColor: 'blue.200',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: 'blue.50',
},
})}
>
<span>{HELP_LEVEL_ICONS[(currentLevel + 1) as HelpLevel]}</span>
<span>More Help: {HELP_LEVEL_LABELS[(currentLevel + 1) as HelpLevel]}</span>
</button>
)}
{/* Max level indicator */}
{maxLevelUsed > 0 && (
<div
className={css({
fontSize: '0.75rem',
color: 'gray.400',
textAlign: 'center',
})}
>
Help used: Level {maxLevelUsed}
</div>
)}
</div>
)
}
export default PracticeHelpPanel