Files
soroban-abacus-flashcards/apps/web/src/components/practice/ProblemToReview.tsx
Thomas Hallock c522620e46 refactor(help): rename helpLevel terminology to hadHelp boolean
The codebase previously used "help level" terminology (HelpLevel type,
helpLevelUsed field, helpLevelWeight function) which implied a graduated
scale. Since the system now only tracks whether help was used or not,
this renames everything to use proper boolean terminology.

Changes:
- Delete HelpLevel type, use boolean directly
- Rename helpLevelUsed → hadHelp in SlotResult
- Rename lastHelpLevel → lastHadHelp in PlayerSkillMastery schema
- Rename helpLevelWeight() → helpWeight() with boolean parameter
- Update all components, tests, stories, and documentation
- Add database migration for column rename

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:04:52 -06:00

480 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Problem To Review Component
*
* A unified problem summary component with progressive disclosure.
* Shows a single problem representation that can be expanded to reveal
* skill annotations, purpose explanation, and timing details.
*
* Key features:
* - Single problem representation (never duplicated between collapsed/expanded)
* - In collapsed mode: shows problem + weak skills (up to 3, ordered by BKT severity)
* - In expanded mode: annotates the SAME problem with skill breakdown per term
* - Part type indicator (🧮 Abacus, 🧠 Visualize, 💭 Mental)
* - Purpose explanation (focus, reinforce, review, challenge)
* - Attention reason badges (incorrect, slow, help-used)
*/
'use client'
import { useState } from 'react'
import { css } from '../../../styled-system/css'
import type { SkillBktResult } from '@/lib/curriculum/bkt'
import { AnnotatedProblem } from './AnnotatedProblem'
import { calculateAutoPauseInfo, formatMs } from './autoPauseCalculator'
import { getPurposeColors, getPurposeConfig } from './purposeExplanations'
import {
type AttentionReason,
getPartTypeLabel,
type ProblemNeedingAttention,
} from './sessionSummaryUtils'
import { getWeakSkillsForProblem } from './weakSkillUtils'
import { WeakSkillsSummary } from './WeakSkillsSummary'
export interface ProblemToReviewProps {
/** The problem that needs attention */
problem: ProblemNeedingAttention
/** All results up to this problem (for auto-pause calculation) */
allResultsBeforeThis: import('@/db/schema/session-plans').SlotResult[]
/** BKT mastery data for skills */
skillMasteries?: Map<string, SkillBktResult> | Record<string, SkillBktResult>
/** Dark mode */
isDark: boolean
}
/**
* Get emoji for part type
*/
function getPartTypeEmoji(type: string): string {
switch (type) {
case 'abacus':
return '🧮'
case 'visualization':
return '🧠'
case 'linear':
return '💭'
default:
return '📝'
}
}
/**
* Attention reason badge
*/
function ReasonBadge({ reason, isDark }: { reason: AttentionReason; isDark: boolean }) {
const config = {
incorrect: { label: 'Incorrect', color: 'red', emoji: '❌' },
slow: { label: 'Slow', color: 'yellow', emoji: '⏱️' },
'help-used': { label: 'Help used', color: 'orange', emoji: '💡' },
}[reason]
return (
<span
data-reason={reason}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.6875rem',
fontWeight: '500',
backgroundColor: isDark ? `${config.color}.900` : `${config.color}.100`,
color: isDark ? `${config.color}.300` : `${config.color}.700`,
})}
>
{config.emoji} {config.label}
</span>
)
}
/**
* Purpose badge with explanation (shown in expanded mode)
*/
function PurposeBadge({
purpose,
isDark,
showExplanation,
}: {
purpose: string
isDark: boolean
showExplanation?: boolean
}) {
const config = getPurposeConfig(purpose as 'focus' | 'reinforce' | 'review' | 'challenge')
const colors = getPurposeColors(purpose as 'focus' | 'reinforce' | 'review' | 'challenge', isDark)
return (
<div
data-element="purpose-badge"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.125rem 0.5rem',
borderRadius: '4px',
fontSize: '0.6875rem',
fontWeight: '500',
backgroundColor: colors.background,
color: colors.text,
width: 'fit-content',
})}
>
{config.emoji} {config.shortLabel}
</span>
{showExplanation && (
<span
className={css({
fontSize: '0.6875rem',
color: isDark ? 'gray.400' : 'gray.500',
fontStyle: 'italic',
lineHeight: 1.3,
})}
>
{config.shortExplanation}
</span>
)}
</div>
)
}
export function ProblemToReview({
problem,
allResultsBeforeThis,
skillMasteries,
isDark,
}: ProblemToReviewProps) {
const [isExpanded, setIsExpanded] = useState(false)
const { result, slot, part, problemNumber, reasons } = problem
const { problem: generatedProblem } = slot
const isIncorrect = !result.isCorrect
// Calculate auto-pause stats for timing info
const autoPauseInfo = calculateAutoPauseInfo(allResultsBeforeThis)
// Get weak skills for this problem based on BKT
const weakSkillsResult = getWeakSkillsForProblem(
result.skillsExercised,
skillMasteries ?? {},
3 // Max display in collapsed mode
)
if (!generatedProblem) return null
return (
<div
data-component="problem-to-review"
data-problem-number={problemNumber}
className={css({
display: 'flex',
flexDirection: 'column',
borderRadius: '8px',
border: '1px solid',
borderColor: isIncorrect
? isDark
? 'red.700'
: 'red.200'
: isDark
? 'yellow.700'
: 'yellow.200',
backgroundColor: isIncorrect
? isDark
? 'red.900/30'
: 'red.50'
: isDark
? 'yellow.900/30'
: 'yellow.50',
overflow: 'hidden',
})}
>
{/* Header row with problem number, part type, reasons, and toggle */}
<button
type="button"
data-element="problem-header"
onClick={() => setIsExpanded(!isExpanded)}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0.5rem 1rem',
backgroundColor: isDark ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.02)',
border: 'none',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700/50' : 'gray.200/50',
width: '100%',
cursor: 'pointer',
textAlign: 'left',
_hover: {
backgroundColor: isDark ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)',
},
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Problem number */}
<span
className={css({
fontWeight: 'bold',
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
#{problemNumber}
</span>
{/* Part type indicator */}
<span
data-element="part-type"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
fontSize: '0.625rem',
fontWeight: '500',
backgroundColor: isDark ? 'gray.700' : 'gray.100',
color: isDark ? 'gray.300' : 'gray.600',
})}
>
{getPartTypeEmoji(part.type)} {getPartTypeLabel(part.type)}
</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Reason badges */}
<div
className={css({
display: 'flex',
gap: '0.375rem',
flexWrap: 'wrap',
})}
>
{reasons.map((reason) => (
<ReasonBadge key={reason} reason={reason} isDark={isDark} />
))}
</div>
{/* Expand/collapse indicator */}
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1.25rem',
height: '1.25rem',
fontSize: '0.625rem',
color: isDark ? 'gray.400' : 'gray.500',
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.25s ease-out',
})}
>
</span>
</div>
</button>
{/* Main content: single problem representation */}
<div
data-element="problem-content"
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '0.75rem 1rem',
})}
>
{/* The problem - uses AnnotatedProblem for unified collapsed/expanded display */}
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
gap: '1rem',
})}
>
{/* Problem display - always vertical, annotated when expanded */}
<AnnotatedProblem
terms={generatedProblem.terms}
answer={generatedProblem.answer}
studentAnswer={result.studentAnswer}
isCorrect={result.isCorrect}
trace={generatedProblem.generationTrace}
skillMasteries={skillMasteries}
expanded={isExpanded}
isDark={isDark}
/>
{/* Collapsed mode: show weak skills summary next to problem */}
{weakSkillsResult.weakSkills.length > 0 && (
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
paddingTop: '0.25rem',
opacity: isExpanded ? 0 : 1,
transform: isExpanded ? 'translateX(-8px)' : 'translateX(0)',
transition: 'opacity 0.2s ease-out, transform 0.2s ease-out',
pointerEvents: isExpanded ? 'none' : 'auto',
})}
>
<span
className={css({
fontSize: '0.625rem',
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Weak Skills
</span>
<WeakSkillsSummary weakSkills={weakSkillsResult} isDark={isDark} />
</div>
)}
</div>
{/* Expanded mode: additional details with smooth animation */}
<div
data-element="expanded-details-wrapper"
className={css({
display: 'grid',
gridTemplateRows: isExpanded ? '1fr' : '0fr',
transition: 'grid-template-rows 0.25s ease-out',
})}
>
<div
data-element="expanded-details"
className={css({
overflow: 'hidden',
})}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
paddingTop: '0.5rem',
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
opacity: isExpanded ? 1 : 0,
transition: 'opacity 0.2s ease-out',
})}
>
{/* Purpose explanation */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
>
<span
className={css({
fontSize: '0.6875rem',
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Purpose
</span>
<PurposeBadge purpose={slot.purpose} isDark={isDark} showExplanation />
</div>
{/* Timing info */}
<div
className={css({
display: 'flex',
gap: '1rem',
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span>Response time: {formatMs(result.responseTimeMs)}</span>
{result.usedOnScreenAbacus && (
<span
className={css({
color: isDark ? 'blue.400' : 'blue.600',
})}
>
🧮 Used on-screen abacus
</span>
)}
{result.hadHelp && (
<span
className={css({
color: isDark ? 'orange.400' : 'orange.600',
})}
>
💡 Used help
</span>
)}
</div>
{/* Threshold comparison for slow responses */}
{reasons.includes('slow') && autoPauseInfo.threshold > 0 && (
<span
className={css({
fontSize: '0.6875rem',
fontStyle: 'italic',
color: isDark ? 'yellow.400' : 'yellow.600',
})}
>
Response time exceeded auto-pause threshold of{' '}
{formatMs(autoPauseInfo.threshold)}
</span>
)}
{/* Weak skills full list (if more than shown in collapsed) */}
{weakSkillsResult.weakSkills.length > 0 && (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
>
<span
className={css({
fontSize: '0.6875rem',
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.500',
textTransform: 'uppercase',
letterSpacing: '0.05em',
})}
>
Weak Skills ({weakSkillsResult.weakSkills.length})
</span>
<WeakSkillsSummary weakSkills={weakSkillsResult} expanded isDark={isDark} />
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
export default ProblemToReview