From a27fb0c9a406fabe4b6c8cf3b5fd0b98cc66bf47 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 11 Dec 2025 05:05:38 -0600 Subject: [PATCH] feat(practice): improve session summary UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/practice/SessionSummary.tsx | 898 +++++++++++------- 1 file changed, 576 insertions(+), 322 deletions(-) diff --git a/apps/web/src/components/practice/SessionSummary.tsx b/apps/web/src/components/practice/SessionSummary.tsx index 105060bc..2a71d1ce 100644 --- a/apps/web/src/components/practice/SessionSummary.tsx +++ b/apps/web/src/components/practice/SessionSummary.tsx @@ -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 = { + 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({ - {/* Skill breakdown */} - {skillBreakdown.length > 0 && ( + {/* Skill breakdown by category */} + {skillCategories.length > 0 && (
- {skillBreakdown.map((skill) => ( -
( +
summary': { + listStyle: 'none', + cursor: 'pointer', + '&::-webkit-details-marker': { display: 'none' }, + }, })} > -
- {formatSkillName(skill.skillId)} -
-
= 0.8 + flex: 1, + fontSize: '0.875rem', + fontWeight: 'bold', + color: isDark ? 'gray.200' : 'gray.800', + })} + > + {category.categoryName} +
+
+
= 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}%` }} + /> +
+
= 0.8 ? 'green.400' - : skill.accuracy >= 0.6 + : category.accuracy >= 0.6 ? 'yellow.400' : 'red.400' - : skill.accuracy >= 0.8 - ? 'green.500' - : skill.accuracy >= 0.6 - ? 'yellow.500' - : 'red.500', - borderRadius: '4px', - transition: 'width 0.5s ease', + : category.accuracy >= 0.8 + ? 'green.600' + : category.accuracy >= 0.6 + ? 'yellow.600' + : 'red.600', + minWidth: '36px', + textAlign: 'right', })} - style={{ width: `${skill.accuracy * 100}%` }} - /> -
+ > + {category.correct}/{category.total} +
+ + + {/* Individual skills within category (expanded content) */}
= 0.8 - ? 'green.400' - : skill.accuracy >= 0.6 - ? 'yellow.400' - : 'red.400' - : skill.accuracy >= 0.8 - ? 'green.600' - : skill.accuracy >= 0.6 - ? 'yellow.600' - : 'red.600', - minWidth: '40px', - textAlign: 'right', + display: 'flex', + flexDirection: 'column', + gap: '0.375rem', + paddingLeft: '0.75rem', + paddingTop: '0.5rem', })} > - {skill.correct}/{skill.total} + {category.skills.map((skill) => ( +
+
+ {formatSkillValue(skill.skillId)} +
+
+
= 0.8 + ? 'green.400' + : skill.accuracy >= 0.6 + ? 'yellow.400' + : 'red.400' + : skill.accuracy >= 0.8 + ? 'green.500' + : skill.accuracy >= 0.6 + ? 'yellow.500' + : 'red.500', + borderRadius: '2px', + })} + style={{ width: `${skill.accuracy * 100}%` }} + /> +
+
= 0.8 + ? 'green.400' + : skill.accuracy >= 0.6 + ? 'yellow.400' + : 'red.400' + : skill.accuracy >= 0.8 + ? 'green.600' + : skill.accuracy >= 0.6 + ? 'yellow.600' + : 'red.600', + minWidth: '28px', + textAlign: 'right', + })} + > + {skill.correct}/{skill.total} +
+
+ ))}
-
+
))}
)} - {/* Problem review - answered problems with results (collapsed by default) */} - {results.length > 0 && ( -
- - Review Answered Problems ({totalProblems}) - - -
- {results.map((result, index) => ( -
- {result.isCorrect ? '✓' : '✗'} - - {formatProblem(result.problem.terms)} = {result.problem.answer} - - {!result.isCorrect && ( - - {result.studentAnswer} - - )} -
- ))} -
-
- )} - - {/* View all planned problems - shows full problem set by part (for teachers) */} -
- - View All Planned Problems ({getTotalPlannedProblems(plan.parts)}) - -
-
- {part.slots.map((slot) => { - // Find if this slot has been answered - const result = results.find( - (r) => r.partNumber === part.partNumber && r.slotIndex === slot.index - ) - const problem = result?.problem ?? slot.problem + {/* Vertical parts: grid layout for compact problem cards */} + {isVerticalPart(part.type) ? ( +
+ {part.slots.map((slot) => { + const result = results.find( + (r) => r.partNumber === part.partNumber && r.slotIndex === slot.index + ) + const problem = result?.problem ?? slot.problem - return ( -
- {/* Status indicator */} - - {result ? (result.isCorrect ? '✓' : '✗') : '○'} - - - {/* Problem number */} - - #{slot.index + 1} - - - {/* Problem content */} - {problem ? ( + {/* Status indicator */} - {formatProblem(problem.terms)} = {problem.answer} - - ) : ( - - (not yet generated) - - )} - - {/* Purpose tag */} - - {slot.purpose} - - - {/* Wrong answer (if incorrect) */} - {result && !result.isCorrect && ( - - {result.studentAnswer} + {result ? (result.isCorrect ? '✓' : '✗') : '○'} - )} -
- ) - })} -
+ + {/* Problem display */} + {problem ? ( + + ) : ( + + ? + + )} +
+ ) + })} +
+ ) : ( + /* Linear parts: list layout for horizontal equations */ +
+ {part.slots.map((slot) => { + const result = results.find( + (r) => r.partNumber === part.partNumber && r.slotIndex === slot.index + ) + const problem = result?.problem ?? slot.problem + + return ( +
+ {/* Status indicator */} + + {result ? (result.isCorrect ? '✓' : '✗') : '○'} + + + {/* Problem content */} + {problem ? ( + + ) : ( + + (not yet generated) + + )} +
+ ) + })} +
+ )} ))} -
+ {/* Action buttons */}
() 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 ( +
+ {terms.map((term, i) => { + const isNegative = term < 0 + const absValue = Math.abs(term) + const digits = absValue.toString() + const padding = maxDigits - digits.length + + return ( +
+ {/* Sign: show − for negative terms, + for additions after first (but usually omit +) */} + + {i === 0 ? '' : isNegative ? '−' : ''} + + {/* Padding for alignment */} + {padding > 0 && ( + {' '.repeat(padding)} + )} + {digits} +
+ ) + })} + {/* Answer line */} +
+ + + {answer} + + {/* Show wrong answer if incorrect */} + {isCorrect === false && studentAnswer !== undefined && ( + + {studentAnswer} + + )} +
+
+ ) +} + +/** + * 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 ( + + {equation} = + + {answer} + + {/* Show wrong answer if incorrect */} + {isCorrect === false && studentAnswer !== undefined && ( + + {studentAnswer} + + )} + + ) +} + export default SessionSummary