feat(practice): improve session summary UI
- Group skills by category (pedagogical order: basic → 5-complements → 10-complements) - Make skill categories collapsible with aggregate stats in header - Use vertical layout for abacus/visualization problems (compact workbook style) - Use horizontal layout for mental math problems (equation format) - Remove duplicate "Review Answered Problems" section - Remove "Review Problems" heading (self-evident) - Fix part names: "Abacus", "Visualize", "Mental Math" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f95456dadc
commit
a27fb0c9a4
|
|
@ -20,6 +20,33 @@ interface SkillBreakdown {
|
|||
accuracy: number
|
||||
}
|
||||
|
||||
interface SkillCategoryGroup {
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
skills: SkillBreakdown[]
|
||||
/** Aggregate stats for the category */
|
||||
correct: number
|
||||
total: number
|
||||
accuracy: number
|
||||
}
|
||||
|
||||
/** Ordered list of skill categories (pedagogical progression) */
|
||||
const SKILL_CATEGORY_ORDER = [
|
||||
'basic',
|
||||
'fiveComplements',
|
||||
'tenComplements',
|
||||
'fiveComplementsSub',
|
||||
'tenComplementsSub',
|
||||
] as const
|
||||
|
||||
const SKILL_CATEGORY_NAMES: Record<string, string> = {
|
||||
basic: 'Basic Operations',
|
||||
fiveComplements: '5-Complements (Addition)',
|
||||
tenComplements: '10-Complements (Addition)',
|
||||
fiveComplementsSub: '5-Complements (Subtraction)',
|
||||
tenComplementsSub: '10-Complements (Subtraction)',
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSummary - Shows results after completing a practice session
|
||||
*
|
||||
|
|
@ -53,8 +80,8 @@ export function SessionSummary({
|
|||
const sessionDurationMinutes =
|
||||
startedAtMs && completedAtMs ? (completedAtMs - startedAtMs) / 1000 / 60 : 0
|
||||
|
||||
// Calculate skill breakdown
|
||||
const skillBreakdown = calculateSkillBreakdown(results)
|
||||
// Calculate skill breakdown grouped by category
|
||||
const skillCategories = calculateSkillBreakdownByCategory(results)
|
||||
|
||||
// Determine overall performance message
|
||||
const performanceMessage = getPerformanceMessage(accuracy)
|
||||
|
|
@ -297,8 +324,8 @@ export function SessionSummary({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill breakdown */}
|
||||
{skillBreakdown.length > 0 && (
|
||||
{/* Skill breakdown by category */}
|
||||
{skillCategories.length > 0 && (
|
||||
<div
|
||||
data-section="skill-breakdown"
|
||||
className={css({
|
||||
|
|
@ -323,34 +350,131 @@ export function SessionSummary({
|
|||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
{skillBreakdown.map((skill) => (
|
||||
<div
|
||||
key={skill.skillId}
|
||||
data-element="skill-row"
|
||||
{skillCategories.map((category) => (
|
||||
<details
|
||||
key={category.categoryId}
|
||||
data-element="skill-category"
|
||||
className={css({
|
||||
'& > summary': {
|
||||
listStyle: 'none',
|
||||
cursor: 'pointer',
|
||||
'&::-webkit-details-marker': { display: 'none' },
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Category header with aggregate stats (clickable summary) */}
|
||||
<summary
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
paddingBottom: '0.375rem',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
_hover: {
|
||||
backgroundColor: isDark ? 'gray.750' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
{formatSkillName(skill.skillId)}
|
||||
{category.categoryName}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '120px',
|
||||
height: '8px',
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '4px',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '100%',
|
||||
backgroundColor: isDark
|
||||
? category.accuracy >= 0.8
|
||||
? 'green.400'
|
||||
: category.accuracy >= 0.6
|
||||
? 'yellow.400'
|
||||
: 'red.400'
|
||||
: category.accuracy >= 0.8
|
||||
? 'green.500'
|
||||
: category.accuracy >= 0.6
|
||||
? 'yellow.500'
|
||||
: 'red.500',
|
||||
borderRadius: '3px',
|
||||
})}
|
||||
style={{ width: `${category.accuracy * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark
|
||||
? category.accuracy >= 0.8
|
||||
? 'green.400'
|
||||
: category.accuracy >= 0.6
|
||||
? 'yellow.400'
|
||||
: 'red.400'
|
||||
: category.accuracy >= 0.8
|
||||
? 'green.600'
|
||||
: category.accuracy >= 0.6
|
||||
? 'yellow.600'
|
||||
: 'red.600',
|
||||
minWidth: '36px',
|
||||
textAlign: 'right',
|
||||
})}
|
||||
>
|
||||
{category.correct}/{category.total}
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
{/* Individual skills within category (expanded content) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
paddingLeft: '0.75rem',
|
||||
paddingTop: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{category.skills.map((skill) => (
|
||||
<div
|
||||
key={skill.skillId}
|
||||
data-element="skill-row"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
{formatSkillValue(skill.skillId)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '60px',
|
||||
height: '4px',
|
||||
backgroundColor: isDark ? 'gray.700' : 'gray.200',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
|
|
@ -368,16 +492,14 @@ export function SessionSummary({
|
|||
: skill.accuracy >= 0.6
|
||||
? 'yellow.500'
|
||||
: 'red.500',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.5s ease',
|
||||
borderRadius: '2px',
|
||||
})}
|
||||
style={{ width: `${skill.accuracy * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.6875rem',
|
||||
color: isDark
|
||||
? skill.accuracy >= 0.8
|
||||
? 'green.400'
|
||||
|
|
@ -389,7 +511,7 @@ export function SessionSummary({
|
|||
: skill.accuracy >= 0.6
|
||||
? 'yellow.600'
|
||||
: 'red.600',
|
||||
minWidth: '40px',
|
||||
minWidth: '28px',
|
||||
textAlign: 'right',
|
||||
})}
|
||||
>
|
||||
|
|
@ -398,92 +520,15 @@ export function SessionSummary({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Problem review - answered problems with results (collapsed by default) */}
|
||||
{results.length > 0 && (
|
||||
<details
|
||||
data-section="problem-review"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<summary
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Review Answered Problems ({totalProblems})
|
||||
</summary>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-element="problem-review-item"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: isDark
|
||||
? result.isCorrect
|
||||
? 'green.900'
|
||||
: 'red.900'
|
||||
: result.isCorrect
|
||||
? 'green.50'
|
||||
: 'red.50',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
color: isDark ? 'gray.200' : 'inherit',
|
||||
})}
|
||||
>
|
||||
<span>{result.isCorrect ? '✓' : '✗'}</span>
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{formatProblem(result.problem.terms)} = {result.problem.answer}
|
||||
</span>
|
||||
{!result.isCorrect && (
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'red.400' : 'red.600',
|
||||
textDecoration: 'line-through',
|
||||
})}
|
||||
>
|
||||
{result.studentAnswer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View all planned problems - shows full problem set by part (for teachers) */}
|
||||
<details
|
||||
data-section="all-planned-problems"
|
||||
{/* Problems by part - always visible */}
|
||||
<div
|
||||
data-section="problems-by-part"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
backgroundColor: isDark ? 'gray.800' : 'gray.50',
|
||||
|
|
@ -492,23 +537,8 @@ export function SessionSummary({
|
|||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<summary
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
},
|
||||
})}
|
||||
>
|
||||
View All Planned Problems ({getTotalPlannedProblems(plan.parts)})
|
||||
</summary>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5rem',
|
||||
|
|
@ -538,15 +568,16 @@ export function SessionSummary({
|
|||
</span>
|
||||
</h4>
|
||||
|
||||
{/* Vertical parts: grid layout for compact problem cards */}
|
||||
{isVerticalPart(part.type) ? (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{part.slots.map((slot) => {
|
||||
// Find if this slot has been answered
|
||||
const result = results.find(
|
||||
(r) => r.partNumber === part.partNumber && r.slotIndex === slot.index
|
||||
)
|
||||
|
|
@ -555,7 +586,80 @@ export function SessionSummary({
|
|||
return (
|
||||
<div
|
||||
key={slot.index}
|
||||
data-element="planned-problem-item"
|
||||
data-element="vertical-problem-item"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: result
|
||||
? isDark
|
||||
? result.isCorrect
|
||||
? 'green.900'
|
||||
: 'red.900'
|
||||
: result.isCorrect
|
||||
? 'green.50'
|
||||
: 'red.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
borderRadius: '6px',
|
||||
minWidth: '3.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{result ? (result.isCorrect ? '✓' : '✗') : '○'}
|
||||
</span>
|
||||
|
||||
{/* Problem display */}
|
||||
{problem ? (
|
||||
<CompactVerticalProblem
|
||||
terms={problem.terms}
|
||||
answer={problem.answer}
|
||||
studentAnswer={result?.studentAnswer}
|
||||
isCorrect={result?.isCorrect}
|
||||
isDark={isDark}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
fontStyle: 'italic',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Linear parts: list layout for horizontal equations */
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
})}
|
||||
>
|
||||
{part.slots.map((slot) => {
|
||||
const result = results.find(
|
||||
(r) => r.partNumber === part.partNumber && r.slotIndex === slot.index
|
||||
)
|
||||
const problem = result?.problem ?? slot.problem
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.index}
|
||||
data-element="linear-problem-item"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
@ -573,8 +677,6 @@ export function SessionSummary({
|
|||
? 'gray.700'
|
||||
: 'white',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8125rem',
|
||||
color: isDark ? 'gray.200' : 'inherit',
|
||||
})}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
|
|
@ -587,27 +689,15 @@ export function SessionSummary({
|
|||
{result ? (result.isCorrect ? '✓' : '✗') : '○'}
|
||||
</span>
|
||||
|
||||
{/* Problem number */}
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
fontSize: '0.75rem',
|
||||
minWidth: '1.5rem',
|
||||
})}
|
||||
>
|
||||
#{slot.index + 1}
|
||||
</span>
|
||||
|
||||
{/* Problem content */}
|
||||
{problem ? (
|
||||
<span
|
||||
className={css({
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{formatProblem(problem.terms)} = {problem.answer}
|
||||
</span>
|
||||
<CompactLinearProblem
|
||||
terms={problem.terms}
|
||||
answer={problem.answer}
|
||||
studentAnswer={result?.studentAnswer}
|
||||
isCorrect={result?.isCorrect}
|
||||
isDark={isDark}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={css({
|
||||
|
|
@ -619,69 +709,15 @@ export function SessionSummary({
|
|||
(not yet generated)
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Purpose tag */}
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.625rem',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '4px',
|
||||
textTransform: 'uppercase',
|
||||
backgroundColor: isDark
|
||||
? slot.purpose === 'focus'
|
||||
? 'blue.900'
|
||||
: slot.purpose === 'reinforce'
|
||||
? 'purple.900'
|
||||
: slot.purpose === 'challenge'
|
||||
? 'orange.900'
|
||||
: 'gray.700'
|
||||
: slot.purpose === 'focus'
|
||||
? 'blue.100'
|
||||
: slot.purpose === 'reinforce'
|
||||
? 'purple.100'
|
||||
: slot.purpose === 'challenge'
|
||||
? 'orange.100'
|
||||
: 'gray.100',
|
||||
color: isDark
|
||||
? slot.purpose === 'focus'
|
||||
? 'blue.300'
|
||||
: slot.purpose === 'reinforce'
|
||||
? 'purple.300'
|
||||
: slot.purpose === 'challenge'
|
||||
? 'orange.300'
|
||||
: 'gray.400'
|
||||
: slot.purpose === 'focus'
|
||||
? 'blue.700'
|
||||
: slot.purpose === 'reinforce'
|
||||
? 'purple.700'
|
||||
: slot.purpose === 'challenge'
|
||||
? 'orange.700'
|
||||
: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{slot.purpose}
|
||||
</span>
|
||||
|
||||
{/* Wrong answer (if incorrect) */}
|
||||
{result && !result.isCorrect && (
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'red.400' : 'red.600',
|
||||
textDecoration: 'line-through',
|
||||
fontSize: '0.75rem',
|
||||
})}
|
||||
>
|
||||
{result.studentAnswer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
|
|
@ -738,7 +774,8 @@ export function SessionSummary({
|
|||
)
|
||||
}
|
||||
|
||||
function calculateSkillBreakdown(results: SlotResult[]): SkillBreakdown[] {
|
||||
function calculateSkillBreakdownByCategory(results: SlotResult[]): SkillCategoryGroup[] {
|
||||
// First, collect all skills with their stats
|
||||
const skillMap = new Map<string, { correct: number; total: number }>()
|
||||
|
||||
for (const result of results) {
|
||||
|
|
@ -750,43 +787,75 @@ function calculateSkillBreakdown(results: SlotResult[]): SkillBreakdown[] {
|
|||
}
|
||||
}
|
||||
|
||||
return Array.from(skillMap.entries())
|
||||
.map(([skillId, stats]) => ({
|
||||
// Group skills by category
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ skills: SkillBreakdown[]; correct: number; total: number }
|
||||
>()
|
||||
|
||||
for (const [skillId, stats] of skillMap.entries()) {
|
||||
const categoryId = skillId.split('.')[0] || 'other'
|
||||
const current = categoryMap.get(categoryId) || { skills: [], correct: 0, total: 0 }
|
||||
|
||||
current.skills.push({
|
||||
skillId,
|
||||
...stats,
|
||||
accuracy: stats.correct / stats.total,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
accuracy: stats.total > 0 ? stats.correct / stats.total : 0,
|
||||
})
|
||||
current.correct += stats.correct
|
||||
current.total += stats.total
|
||||
|
||||
categoryMap.set(categoryId, current)
|
||||
}
|
||||
|
||||
// Sort categories by pedagogical order, then build result
|
||||
const result: SkillCategoryGroup[] = []
|
||||
|
||||
for (const categoryId of SKILL_CATEGORY_ORDER) {
|
||||
const categoryData = categoryMap.get(categoryId)
|
||||
if (categoryData && categoryData.skills.length > 0) {
|
||||
// Sort skills within category by total count (most practiced first)
|
||||
categoryData.skills.sort((a, b) => b.total - a.total)
|
||||
|
||||
result.push({
|
||||
categoryId,
|
||||
categoryName: SKILL_CATEGORY_NAMES[categoryId] || categoryId,
|
||||
skills: categoryData.skills,
|
||||
correct: categoryData.correct,
|
||||
total: categoryData.total,
|
||||
accuracy: categoryData.total > 0 ? categoryData.correct / categoryData.total : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add any categories not in the predefined order (shouldn't happen, but just in case)
|
||||
for (const [categoryId, categoryData] of categoryMap.entries()) {
|
||||
if (!SKILL_CATEGORY_ORDER.includes(categoryId as (typeof SKILL_CATEGORY_ORDER)[number])) {
|
||||
categoryData.skills.sort((a, b) => b.total - a.total)
|
||||
|
||||
result.push({
|
||||
categoryId,
|
||||
categoryName: SKILL_CATEGORY_NAMES[categoryId] || categoryId,
|
||||
skills: categoryData.skills,
|
||||
correct: categoryData.correct,
|
||||
total: categoryData.total,
|
||||
accuracy: categoryData.total > 0 ? categoryData.correct / categoryData.total : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function formatSkillName(skillId: string): string {
|
||||
// Convert skill IDs like "fiveComplements.4=5-1" to readable names
|
||||
/** Format just the skill value part (e.g., "4=5-1" from "fiveComplements.4=5-1") */
|
||||
function formatSkillValue(skillId: string): string {
|
||||
const parts = skillId.split('.')
|
||||
if (parts.length === 2) {
|
||||
const [category, skill] = parts
|
||||
const categoryName =
|
||||
{
|
||||
fiveComplements: '5-Complements',
|
||||
tenComplements: '10-Complements',
|
||||
directAddition: 'Direct Addition',
|
||||
directSubtraction: 'Direct Subtraction',
|
||||
basic: 'Basic',
|
||||
}[category] || category
|
||||
|
||||
return `${categoryName}: ${skill}`
|
||||
return parts[1]
|
||||
}
|
||||
return skillId
|
||||
}
|
||||
|
||||
function formatProblem(terms: number[]): string {
|
||||
return terms
|
||||
.map((t, i) => {
|
||||
if (i === 0) return t.toString()
|
||||
return t < 0 ? ` - ${Math.abs(t)}` : ` + ${t}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function getPerformanceMessage(accuracy: number): string {
|
||||
if (accuracy >= 0.95) return 'Outstanding! You are a math champion!'
|
||||
if (accuracy >= 0.9) return 'Excellent work! Keep up the great practice!'
|
||||
|
|
@ -796,21 +865,206 @@ function getPerformanceMessage(accuracy: number): string {
|
|||
return "Keep practicing! You'll get better with each session!"
|
||||
}
|
||||
|
||||
function getTotalPlannedProblems(parts: SessionPart[]): number {
|
||||
return parts.reduce((sum, part) => sum + part.slots.length, 0)
|
||||
}
|
||||
|
||||
function getPartTypeName(type: SessionPart['type']): string {
|
||||
switch (type) {
|
||||
case 'abacus':
|
||||
return 'Use Abacus'
|
||||
return 'Abacus'
|
||||
case 'visualization':
|
||||
return 'Mental Math (Visualization)'
|
||||
return 'Visualize'
|
||||
case 'linear':
|
||||
return 'Mental Math (Linear)'
|
||||
return 'Mental Math'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if part type uses vertical layout (abacus/visualization) vs linear */
|
||||
function isVerticalPart(type: SessionPart['type']): boolean {
|
||||
return type === 'abacus' || type === 'visualization'
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact vertical problem display for review
|
||||
* Shows terms stacked with answer below, like a mini workbook problem
|
||||
*/
|
||||
function CompactVerticalProblem({
|
||||
terms,
|
||||
answer,
|
||||
studentAnswer,
|
||||
isCorrect,
|
||||
isDark,
|
||||
}: {
|
||||
terms: number[]
|
||||
answer: number
|
||||
studentAnswer?: number
|
||||
isCorrect?: boolean
|
||||
isDark: boolean
|
||||
}) {
|
||||
// Calculate max digits for alignment
|
||||
const maxDigits = Math.max(
|
||||
...terms.map((t) => Math.abs(t).toString().length),
|
||||
answer.toString().length
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="compact-vertical-problem"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1.2,
|
||||
})}
|
||||
>
|
||||
{terms.map((term, i) => {
|
||||
const isNegative = term < 0
|
||||
const absValue = Math.abs(term)
|
||||
const digits = absValue.toString()
|
||||
const padding = maxDigits - digits.length
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{/* Sign: show − for negative terms, + for additions after first (but usually omit +) */}
|
||||
<span
|
||||
className={css({
|
||||
width: '0.75em',
|
||||
textAlign: 'center',
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
})}
|
||||
>
|
||||
{i === 0 ? '' : isNegative ? '−' : ''}
|
||||
</span>
|
||||
{/* Padding for alignment */}
|
||||
{padding > 0 && (
|
||||
<span className={css({ visibility: 'hidden' })}>{' '.repeat(padding)}</span>
|
||||
)}
|
||||
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>{digits}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Answer line */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
paddingTop: '0.125rem',
|
||||
marginTop: '0.125rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ width: '0.75em' })} />
|
||||
<span
|
||||
className={css({
|
||||
color:
|
||||
isCorrect === undefined
|
||||
? isDark
|
||||
? 'gray.400'
|
||||
: 'gray.600'
|
||||
: isCorrect
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: isDark
|
||||
? 'red.400'
|
||||
: 'red.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{answer}
|
||||
</span>
|
||||
{/* Show wrong answer if incorrect */}
|
||||
{isCorrect === false && studentAnswer !== undefined && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '0.25rem',
|
||||
color: isDark ? 'red.400' : 'red.500',
|
||||
textDecoration: 'line-through',
|
||||
fontSize: '0.625rem',
|
||||
})}
|
||||
>
|
||||
{studentAnswer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact linear problem display for review
|
||||
* Shows equation in horizontal format: "5 + 3 - 2 = 6"
|
||||
*/
|
||||
function CompactLinearProblem({
|
||||
terms,
|
||||
answer,
|
||||
studentAnswer,
|
||||
isCorrect,
|
||||
isDark,
|
||||
}: {
|
||||
terms: number[]
|
||||
answer: number
|
||||
studentAnswer?: number
|
||||
isCorrect?: boolean
|
||||
isDark: boolean
|
||||
}) {
|
||||
const equation = terms
|
||||
.map((term, i) => {
|
||||
if (i === 0) return String(term)
|
||||
return term < 0 ? ` − ${Math.abs(term)}` : ` + ${term}`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return (
|
||||
<span
|
||||
data-element="compact-linear-problem"
|
||||
className={css({
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8125rem',
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: isDark ? 'gray.300' : 'gray.700' })}>{equation} = </span>
|
||||
<span
|
||||
className={css({
|
||||
color:
|
||||
isCorrect === undefined
|
||||
? isDark
|
||||
? 'gray.400'
|
||||
: 'gray.600'
|
||||
: isCorrect
|
||||
? isDark
|
||||
? 'green.400'
|
||||
: 'green.600'
|
||||
: isDark
|
||||
? 'red.400'
|
||||
: 'red.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{answer}
|
||||
</span>
|
||||
{/* Show wrong answer if incorrect */}
|
||||
{isCorrect === false && studentAnswer !== undefined && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '0.375rem',
|
||||
color: isDark ? 'red.400' : 'red.500',
|
||||
textDecoration: 'line-through',
|
||||
})}
|
||||
>
|
||||
{studentAnswer}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionSummary
|
||||
|
|
|
|||
Loading…
Reference in New Issue